├── .github └── workflows │ └── default.yml ├── .gitignore ├── .npmignore ├── CHANGELOG.md ├── LICENSE ├── package.json ├── readme.md ├── rollup.config.js ├── scripts ├── build ├── docs ├── lint ├── report ├── test ├── test-integration └── validate ├── src ├── builder.ts ├── clause-collection.spec.ts ├── clause-collection.ts ├── clause.spec.ts ├── clause.ts ├── clauses │ ├── create.spec.ts │ ├── create.ts │ ├── delete.spec.ts │ ├── delete.ts │ ├── index.spec.ts │ ├── index.ts │ ├── limit.spec.ts │ ├── limit.ts │ ├── match.spec.ts │ ├── match.ts │ ├── merge.spec.ts │ ├── merge.ts │ ├── node-pattern.spec.ts │ ├── node-pattern.ts │ ├── on-create.spec.ts │ ├── on-create.ts │ ├── on-match.spec.ts │ ├── on-match.ts │ ├── order-by.spec.ts │ ├── order-by.ts │ ├── pattern-clause.spec.ts │ ├── pattern-clause.ts │ ├── pattern.spec.ts │ ├── pattern.ts │ ├── raw.spec.ts │ ├── raw.ts │ ├── relation-pattern.spec.ts │ ├── relation-pattern.ts │ ├── remove.spec.ts │ ├── remove.ts │ ├── return.spec.ts │ ├── return.ts │ ├── set.spec.ts │ ├── set.ts │ ├── skip.spec.ts │ ├── skip.ts │ ├── term-list-clause.spec.ts │ ├── term-list-clause.ts │ ├── union.spec.ts │ ├── union.ts │ ├── unwind.spec.ts │ ├── unwind.ts │ ├── where-comparators.spec.ts │ ├── where-comparators.ts │ ├── where-operators.spec.ts │ ├── where-operators.ts │ ├── where-utils.spec.ts │ ├── where-utils.ts │ ├── where.spec.ts │ ├── where.ts │ ├── with.spec.ts │ └── with.ts ├── connection.ts ├── index.ts ├── parameter-bag.spec.ts ├── parameter-bag.ts ├── parameter-container.spec.ts ├── parameter-container.ts ├── query.spec.ts ├── query.ts ├── transformer.ts ├── utils.spec.ts └── utils.ts ├── test-setup.ts ├── tests ├── connection.test.ts ├── scenarios.test.ts └── utils.ts ├── tsconfig.declaration.json ├── tsconfig.json ├── tslint.json ├── typings └── node-cleanup │ └── index.d.ts └── yarn.lock /.github/workflows/default.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: push 3 | 4 | jobs: 5 | test: 6 | name: Tests (node ${{ matrix.node-version }}) 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | node-version: 11 | - '18' 12 | - '20' 13 | - '21' 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: actions/setup-node@v4 17 | with: 18 | node-version: ${{ matrix.node-version }} 19 | - run: yarn install --frozen-lockfile 20 | - run: yarn lint 21 | - run: yarn build 22 | - run: yarn test:unit 23 | - uses: coverallsapp/github-action@v2 24 | with: 25 | github-token: ${{ secrets.GITHUB_TOKEN }} 26 | flag-name: test-${{ join(matrix.*, '-') }} 27 | parallel: true 28 | 29 | integration-test: 30 | name: Integration tests (neo4j ${{ matrix.neo4j }}, node ${{ matrix.node-version }}) 31 | runs-on: ubuntu-latest 32 | strategy: 33 | matrix: 34 | neo4j: 35 | - '4.4' 36 | - '5.16' 37 | node-version: 38 | - '21' 39 | steps: 40 | - uses: actions/checkout@v4 41 | - uses: actions/setup-node@v4 42 | with: 43 | node-version: ${{ matrix.node-version }} 44 | - run: yarn install --frozen-lockfile 45 | - run: yarn test 46 | env: 47 | NEO4J_VERSION: ${{ matrix.neo4j }} 48 | - uses: coverallsapp/github-action@v2 49 | with: 50 | github-token: ${{ secrets.GITHUB_TOKEN }} 51 | flag-name: integration-test-${{ join(matrix.*, '-') }} 52 | parallel: true 53 | 54 | finialize-coverage: 55 | name: Finalize coverage 56 | needs: 57 | - test 58 | - integration-test 59 | runs-on: ubuntu-latest 60 | steps: 61 | - name: Finalise coverage 62 | uses: coverallsapp/github-action@v2 63 | with: 64 | github-token: ${{ secrets.github_token }} 65 | parallel-finished: true 66 | 67 | deploy: 68 | name: Deploy 69 | needs: 70 | - test 71 | - integration-test 72 | if: github.ref == 'refs/heads/master' 73 | concurrency: 74 | group: deploy 75 | cancel-in-progress: false 76 | runs-on: ubuntu-latest 77 | steps: 78 | - uses: actions/checkout@v4 79 | - uses: actions/setup-node@v4 80 | with: 81 | node-version: 21 82 | - run: yarn install --frozen-lockfile 83 | - run: yarn build 84 | - run: yarn semantic-release 85 | env: 86 | GITHUB_TOKEN: ${{ secrets.github_token }} 87 | NPM_TOKEN: ${{ secrets.npm_token }} 88 | - run: yarn docs 89 | - uses: peaceiris/actions-gh-pages@v3 90 | with: 91 | github_token: ${{ secrets.github_token }} 92 | publish_dir: docs 93 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm_debug.log 3 | yarn-error.log 4 | .idea/ 5 | .nyc_output/ 6 | coverage/ 7 | dist/ 8 | docs/ 9 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm_debug.log 3 | .idea/ 4 | .nyc_output/ 5 | coverage/ 6 | docs/ 7 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [6.0.4](https://github.com/jamesfer/cypher-query-builder/compare/v6.0.3...v6.0.4) (2020-12-21) 2 | 3 | 4 | ### Bug Fixes 5 | 6 | * **build:** add missing build step before releasing ([cd68ec3](https://github.com/jamesfer/cypher-query-builder/commit/cd68ec3e3a970d2c7e274700ae45e3d07fb91b40)), closes [#173](https://github.com/jamesfer/cypher-query-builder/issues/173) 7 | 8 | ## [6.0.3](https://github.com/jamesfer/cypher-query-builder/compare/v6.0.2...v6.0.3) (2020-12-16) 9 | 10 | 11 | ### Bug Fixes 12 | 13 | * **Package:** Reduce package size ([b8238ac](https://github.com/jamesfer/cypher-query-builder/commit/b8238ac6cdde73d6a28c2bd65be0fe72387df123)) 14 | 15 | ## [6.0.2](https://github.com/jamesfer/cypher-query-builder/compare/v6.0.1...v6.0.2) (2020-12-12) 16 | 17 | 18 | ### Bug Fixes 19 | 20 | * **termlist:** fix handling of nested dictionaries ([93a5cd4](https://github.com/jamesfer/cypher-query-builder/commit/93a5cd42b63812905de8a953574cee147f4de390)), closes [#137](https://github.com/jamesfer/cypher-query-builder/issues/137) 21 | 22 | ## [6.0.1](https://github.com/jamesfer/cypher-query-builder/compare/v6.0.0...v6.0.1) (2020-09-26) 23 | 24 | 25 | ### Bug Fixes 26 | 27 | * **skip,limit:** use int object for parameter ([2b18c97](https://github.com/jamesfer/cypher-query-builder/commit/2b18c973163df6ec9c221e9a092d5f66378ed651)), closes [#159](https://github.com/jamesfer/cypher-query-builder/issues/159) 28 | 29 | # [6.0.0](https://github.com/jamesfer/cypher-query-builder/compare/v5.0.4...v6.0.0) (2020-09-17) 30 | 31 | 32 | ### Bug Fixes 33 | 34 | * remove any-observable ([8328434](https://github.com/jamesfer/cypher-query-builder/commit/8328434717f392372292369539484d318b36dbd8)) 35 | * remove any-promise ([f624574](https://github.com/jamesfer/cypher-query-builder/commit/f624574c94552d16b4f55ee17a01a1acd1cf7185)) 36 | * update neo4j driver to 4.0 ([cb0bf1e](https://github.com/jamesfer/cypher-query-builder/commit/cb0bf1e32134adee467aaa09fa02810c9e7e3617)) 37 | 38 | 39 | ### BREAKING CHANGES 40 | 41 | * Removes the any-observable package 42 | * Removes the any-promise package 43 | * Connection.close() now returns a promise instead of acting immediately. 44 | The new neo4j driver changed the behaviour of Driver.close() and this change is consistent 45 | with that. 46 | 47 | ## [5.0.4](https://github.com/jamesfer/cypher-query-builder/compare/v5.0.3...v5.0.4) (2019-12-23) 48 | 49 | 50 | ### Bug Fixes 51 | 52 | * **Transformer:** handle undefined values better ([b819b9a](https://github.com/jamesfer/cypher-query-builder/commit/b819b9a1c9028fce4a11e0086b90617cae2fbf71)), closes [/github.com/neo4j/neo4j-javascript-driver/blob/4.0/types/spatial-types.d.ts#L27](https://github.com//github.com/neo4j/neo4j-javascript-driver/blob/4.0/types/spatial-types.d.ts/issues/L27) 53 | 54 | ## [5.0.3](https://github.com/jamesfer/cypher-query-builder/compare/v5.0.2...v5.0.3) (2019-12-16) 55 | 56 | 57 | ### Bug Fixes 58 | 59 | * update package versions in lockfile ([2d8534e](https://github.com/jamesfer/cypher-query-builder/commit/2d8534ebf8b1f20cdb4a75fc542c592dd27cd9da)) 60 | 61 | ## [5.0.2](https://github.com/jamesfer/cypher-query-builder/compare/v5.0.1...v5.0.2) (2019-12-16) 62 | 63 | 64 | ### Bug Fixes 65 | 66 | * remove rimraf dependency ([e2aa8f3](https://github.com/jamesfer/cypher-query-builder/commit/e2aa8f336f2aee5f4d58864612bb66f71ed0890e)) 67 | * update any-observable ([c5cb388](https://github.com/jamesfer/cypher-query-builder/commit/c5cb3885e573ded3b4d4e8776bef0f41a33c79ed)) 68 | * update lodash version in package.json ([67e71c4](https://github.com/jamesfer/cypher-query-builder/commit/67e71c429cefcedae08f09323c4edde25d646640)) 69 | * update typedoc dependency ([f4bde55](https://github.com/jamesfer/cypher-query-builder/commit/f4bde5546dfafa575f499fb5feb524cb5696ecc3)) 70 | 71 | ## [5.0.1](https://github.com/jamesfer/cypher-query-builder/compare/v5.0.0...v5.0.1) (2019-12-08) 72 | 73 | 74 | ### Bug Fixes 75 | 76 | * export builder, clause and clause collection ([6dde494](https://github.com/jamesfer/cypher-query-builder/commit/6dde494)), closes [#116](https://github.com/jamesfer/cypher-query-builder/issues/116) 77 | 78 | # [5.0.0](https://github.com/jamesfer/cypher-query-builder/compare/v4.4.0...v5.0.0) (2019-09-21) 79 | 80 | 81 | ### Bug Fixes 82 | 83 | * **Set:** replace override option with merge ([91ab4f6](https://github.com/jamesfer/cypher-query-builder/commit/91ab4f6)) 84 | * make error handling more consistent ([56a7591](https://github.com/jamesfer/cypher-query-builder/commit/56a7591)) 85 | 86 | 87 | ### BREAKING CHANGES 88 | 89 | * The run and stream methods of the Connection and Query classes no longer throw 90 | exceptions. Instead they return a rejected promise or an observable that will immediately error. 91 | * **Set:** The default behaviour of the Set clause has changed to use the `=` operator. 92 | This is to be more consistent with cypher. 93 | 94 | # [4.4.0](https://github.com/jamesfer/cypher-query-builder/compare/v4.3.1...v4.4.0) (2019-08-25) 95 | 96 | 97 | ### Features 98 | 99 | * **create:** add unique option ([202bc4c](https://github.com/jamesfer/cypher-query-builder/commit/202bc4c)), closes [#105](https://github.com/jamesfer/cypher-query-builder/issues/105) 100 | * **create:** add unique option ([39c860d](https://github.com/jamesfer/cypher-query-builder/commit/39c860d)), closes [#105](https://github.com/jamesfer/cypher-query-builder/issues/105) 101 | 102 | ## [4.3.1](https://github.com/jamesfer/cypher-query-builder/compare/v4.3.0...v4.3.1) (2019-08-25) 103 | 104 | 105 | ### Bug Fixes 106 | 107 | * **return:** pass options from query interface to return clause ([f35ebda](https://github.com/jamesfer/cypher-query-builder/commit/f35ebda)) 108 | 109 | # [4.3.0](https://github.com/jamesfer/cypher-query-builder/compare/v4.2.0...v4.3.0) (2019-08-25) 110 | 111 | 112 | ### Features 113 | 114 | * **return:** add distinct option ([205960a](https://github.com/jamesfer/cypher-query-builder/commit/205960a)), closes [#90](https://github.com/jamesfer/cypher-query-builder/issues/90) 115 | 116 | # [4.2.0](https://github.com/jamesfer/cypher-query-builder/compare/v4.1.0...v4.2.0) (2019-08-06) 117 | 118 | 119 | ### Features 120 | 121 | * **Where:** allow RegExp objects directly in where clause ([595b6a9](https://github.com/jamesfer/cypher-query-builder/commit/595b6a9)), closes [#13](https://github.com/jamesfer/cypher-query-builder/issues/13) 122 | 123 | # [4.1.0](https://github.com/jamesfer/cypher-query-builder/compare/v4.0.2...v4.1.0) (2019-07-16) 124 | 125 | 126 | ### Features 127 | 128 | * **Union:** create union clause ([7e3b7c8](https://github.com/jamesfer/cypher-query-builder/commit/7e3b7c8)) 129 | 130 | ## [4.0.2](https://github.com/jamesfer/cypher-query-builder/compare/v4.0.1...v4.0.2) (2019-07-16) 131 | 132 | 133 | ### Bug Fixes 134 | 135 | * update lodash version ([8e9687f](https://github.com/jamesfer/cypher-query-builder/commit/8e9687f)) 136 | 137 | ## [4.0.1](https://github.com/jamesfer/cypher-query-builder/compare/v4.0.0...v4.0.1) (2019-06-25) 138 | 139 | 140 | ### Bug Fixes 141 | 142 | * update code style and test errors ([1b94118](https://github.com/jamesfer/cypher-query-builder/commit/1b94118)) 143 | 144 | # [4.0.0](https://github.com/jamesfer/cypher-query-builder/compare/v3.8.5...v4.0.0) (2019-06-25) 145 | 146 | 147 | ### Bug Fixes 148 | 149 | * remove unused rxjs peer dependency ([ae0c95d](https://github.com/jamesfer/cypher-query-builder/commit/ae0c95d)) 150 | * **Delete:** change the default behaviour of delete clause not to use detach ([9f367c7](https://github.com/jamesfer/cypher-query-builder/commit/9f367c7)) 151 | * **Limit:** use a parameter for limit number ([025c873](https://github.com/jamesfer/cypher-query-builder/commit/025c873)) 152 | * **Skip:** use a parameter in skip number ([7f6360c](https://github.com/jamesfer/cypher-query-builder/commit/7f6360c)) 153 | * **Skip, Limit:** make skip and limit only accept number amounts ([cfb62c3](https://github.com/jamesfer/cypher-query-builder/commit/cfb62c3)) 154 | 155 | 156 | ### BREAKING CHANGES 157 | 158 | * **Skip, Limit:** The type of skip and limit clauses no longer accept a string. This will only effect 159 | typescript users, there is no breaking change for javascript users. 160 | * **Delete:** The `.delete` method now uses `detach: false` by default meaning that it will 161 | become a plain old `DELETE` clause in cypher. To retain the previous behaviour of becoming a `DETACH 162 | DELETE` clause by default, use the `.detachDelete` method instead. 163 | * **Skip:** A string expression as the skip number is no longer accepted. The argument must be 164 | a number. 165 | * **Limit:** A string expression as the limit number is no longer accepted. The argument must be 166 | a number. 167 | 168 | ## [3.8.5](https://github.com/jamesfer/cypher-query-builder/compare/v3.8.4...v3.8.5) (2018-11-15) 169 | 170 | 171 | ### Bug Fixes 172 | 173 | * tell rollup to output external modules with node style paths ([248f039](https://github.com/jamesfer/cypher-query-builder/commit/248f039)), closes [#68](https://github.com/jamesfer/cypher-query-builder/issues/68) 174 | 175 | ## [3.8.4](https://github.com/jamesfer/cypher-query-builder/compare/v3.8.3...v3.8.4) (2018-11-06) 176 | 177 | 178 | ### Bug Fixes 179 | 180 | * fix conflicting any promise types ([13a9eff](https://github.com/jamesfer/cypher-query-builder/commit/13a9eff)) 181 | 182 | ## [3.8.3](https://github.com/jamesfer/cypher-query-builder/compare/v3.8.2...v3.8.3) (2018-11-04) 183 | 184 | 185 | ### Bug Fixes 186 | 187 | * fix generated references to lodash types ([9b27bab](https://github.com/jamesfer/cypher-query-builder/commit/9b27bab)) 188 | 189 | ## [3.8.2](https://github.com/jamesfer/cypher-query-builder/compare/v3.8.1...v3.8.2) (2018-11-03) 190 | 191 | 192 | ### Bug Fixes 193 | 194 | * fix how rollup was emitting imports ([110a022](https://github.com/jamesfer/cypher-query-builder/commit/110a022)) 195 | 196 | ## [3.8.1](https://github.com/jamesfer/cypher-query-builder.git/compare/v3.8.0...v3.8.1) (2018-11-03) 197 | 198 | 199 | ### Bug Fixes 200 | 201 | * **Where:** bind where clause parameters during build ([bf0d4c2](https://github.com/jamesfer/cypher-query-builder.git/commit/bf0d4c2)) 202 | 203 | # [3.8.0](https://github.com/jamesfer/cypher-query-builder/compare/v3.7.0...v3.8.0) (2018-10-31) 204 | 205 | 206 | ### Features 207 | 208 | * **Remove:** create remove clause ([9d600b6](https://github.com/jamesfer/cypher-query-builder/commit/9d600b6)) 209 | 210 | # [3.7.0](https://github.com/jamesfer/cypher-query-builder/compare/v3.6.0...v3.7.0) (2018-09-28) 211 | 212 | 213 | ### Bug Fixes 214 | 215 | * **Query:** ensure Query.run doesn't throw synchronously ([feebde0](https://github.com/jamesfer/cypher-query-builder/commit/feebde0)) 216 | 217 | 218 | ### Features 219 | 220 | * support registering observables using any-observable ([57b2089](https://github.com/jamesfer/cypher-query-builder/commit/57b2089)) 221 | * support registering promises using any-promise ([5284e6d](https://github.com/jamesfer/cypher-query-builder/commit/5284e6d)) 222 | 223 | # [3.6.0](https://github.com/jamesfer/cypher-query-builder/compare/v3.5.5...v3.6.0) (2018-09-27) 224 | 225 | 226 | ### Features 227 | 228 | * **Connection:** accept neo4j driver options in connection constructor ([d76d65a](https://github.com/jamesfer/cypher-query-builder/commit/d76d65a)) 229 | 230 | ## [3.5.5](https://github.com/jamesfer/cypher-query-builder/compare/v3.5.4...v3.5.5) (2018-08-14) 231 | 232 | 233 | ### Bug Fixes 234 | 235 | * **Where:** remove side effects from build ([f664ebe](https://github.com/jamesfer/cypher-query-builder/commit/f664ebe)), closes [#42](https://github.com/jamesfer/cypher-query-builder/issues/42) 236 | 237 | ## [3.5.4](https://github.com/jamesfer/cypher-query-builder/compare/v3.5.3...v3.5.4) (2018-08-14) 238 | 239 | 240 | ### Bug Fixes 241 | 242 | * **OrderBy:** accept direction case-insensitively ([728497d](https://github.com/jamesfer/cypher-query-builder/commit/728497d)) 243 | 244 | ## [3.5.3](https://github.com/jamesfer/cypher-query-builder/compare/v3.5.2...v3.5.3) (2018-08-13) 245 | 246 | 247 | ### Bug Fixes 248 | 249 | * **Clause:** match whole variable when inlining using interpolate ([d0588aa](https://github.com/jamesfer/cypher-query-builder/commit/d0588aa)) 250 | 251 | ## [3.5.2](https://github.com/jamesfer/cypher-query-builder/compare/v3.5.1...v3.5.2) (2018-08-13) 252 | 253 | 254 | ### Bug Fixes 255 | 256 | * **Where:** make WhereOp class abstract ([5ccd199](https://github.com/jamesfer/cypher-query-builder/commit/5ccd199)) 257 | 258 | ## [3.5.1](https://github.com/jamesfer/cypher-query-builder/compare/v3.5.0...v3.5.1) (2018-07-08) 259 | 260 | 261 | ### Bug Fixes 262 | 263 | * **Set:** Only use += in Set when value is an object ([c61a37f](https://github.com/jamesfer/cypher-query-builder/commit/c61a37f)) 264 | 265 | # [3.5.0](https://github.com/jamesfer/cypher-query-builder/compare/v3.4.1...v3.5.0) (2018-06-18) 266 | 267 | 268 | ### Bug Fixes 269 | 270 | * **OrderBy:** remove deprecation notice about old constraint style ([2c35ac9](https://github.com/jamesfer/cypher-query-builder/commit/2c35ac9)) 271 | 272 | 273 | ### Features 274 | 275 | * **OrderBy:** add new order by constraint style ([2324831](https://github.com/jamesfer/cypher-query-builder/commit/2324831)), closes [#9](https://github.com/jamesfer/cypher-query-builder/issues/9) 276 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 James Ferguson 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 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cypher-query-builder", 3 | "version": "6.0.4", 4 | "description": "An intuitive, easy to use query builder for Neo4j and Cypher", 5 | "author": "James Ferguson", 6 | "license": "MIT", 7 | "repository": "github:jamesfer/cypher-query-builder", 8 | "main": "dist/cjs5.js", 9 | "module": "dist/esm5.js", 10 | "es2015": "dist/esm2015.js", 11 | "typings": "dist/typings/index.d.ts", 12 | "sideEffects": false, 13 | "engines": { 14 | "node": ">=6" 15 | }, 16 | "keywords": [ 17 | "cypher", 18 | "query", 19 | "builder", 20 | "neo4j", 21 | "orm", 22 | "graph" 23 | ], 24 | "scripts": { 25 | "commit": "git-cz", 26 | "build": "scripts/build declaration && scripts/build rollup", 27 | "build:declaration": "scripts/build declaration", 28 | "build:rollup": "scripts/build rollup", 29 | "docs": "scripts/docs", 30 | "lint": "scripts/lint", 31 | "report": "scripts/report", 32 | "test": "scripts/test-integration", 33 | "test:unit": "scripts/test", 34 | "validate": "scripts/validate" 35 | }, 36 | "config": { 37 | "commitizen": { 38 | "path": "cz-conventional-changelog" 39 | } 40 | }, 41 | "release": { 42 | "verifyConditions": [ 43 | "@semantic-release/changelog", 44 | "@semantic-release/npm", 45 | "@semantic-release/github", 46 | "@semantic-release/git" 47 | ], 48 | "prepare": [ 49 | { 50 | "path": "@semantic-release/changelog", 51 | "changelogFile": "CHANGELOG.md" 52 | }, 53 | "@semantic-release/npm", 54 | { 55 | "path": "@semantic-release/git", 56 | "assets": [ 57 | "package.json", 58 | "CHANGELOG.md" 59 | ], 60 | "message": "chore(release): ${nextRelease.version}\n\n${nextRelease.notes}\n[skip ci]\n" 61 | } 62 | ] 63 | }, 64 | "babel": { 65 | "plugins": [ 66 | "lodash" 67 | ] 68 | }, 69 | "nyc": { 70 | "all": true, 71 | "produce-source-map": true, 72 | "report-dir": "./coverage", 73 | "extension": [ 74 | ".ts", 75 | ".tsx" 76 | ], 77 | "require": [ 78 | "ts-node/register", 79 | "source-map-support/register" 80 | ], 81 | "include": [ 82 | "src/**/*.ts" 83 | ], 84 | "exclude": [ 85 | "**/*.d.ts", 86 | "**/*.spec.ts", 87 | "**/*.mock.ts" 88 | ] 89 | }, 90 | "dependencies": { 91 | "@types/lodash": "^4.14.202", 92 | "@types/node": "^12.6.1", 93 | "lodash": "^4.17.15", 94 | "neo4j-driver": "^4.1.2", 95 | "node-cleanup": "^2.1.2", 96 | "rxjs": "^6.5.2", 97 | "tslib": "^1.10.0" 98 | }, 99 | "devDependencies": { 100 | "@babel/core": "^7.5.0", 101 | "@semantic-release/changelog": "^3.0.4", 102 | "@semantic-release/git": "^7.0.16", 103 | "@types/chai": "^4.0.4", 104 | "@types/chai-as-promised": "^7.1.0", 105 | "@types/mocha": "^5.2.5", 106 | "@types/sinon": "^7.0.13", 107 | "babel-plugin-lodash": "^3.3.4", 108 | "chai": "^4.0.2", 109 | "chai-as-promised": "^7.0.0", 110 | "commitizen": "^4.0.3", 111 | "coveralls": "^3.0.4", 112 | "mocha": "^6.1.4", 113 | "nyc": "^14.1.1", 114 | "rollup": "^1.19.3", 115 | "rollup-plugin-babel": "^4.3.3", 116 | "rollup-plugin-commonjs": "^10.0.1", 117 | "rollup-plugin-node-resolve": "^5.2.0", 118 | "rollup-plugin-typescript": "^1.0.0", 119 | "semantic-release": "^15.13.18", 120 | "sinon": "^7.3.2", 121 | "source-map-support": "^0.5.0", 122 | "ts-node": "^8.3.0", 123 | "tslint": "^5.18.0", 124 | "tslint-config-airbnb": "^5.11.1", 125 | "typedoc": "^0.15.3", 126 | "typescript": "^4.9.5" 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Cypher Query Builder 2 | [![Build Status](https://github.com/jamesfer/cypher-query-builder/workflows/CI/badge.svg)](https://github.com/jamesfer/cypher-query-builder/actions) 3 | [![Coverage Status](https://coveralls.io/repos/github/jamesfer/cypher-query-builder/badge.svg?branch=master)](https://coveralls.io/github/jamesfer/cypher-query-builder?branch=master) 4 | [![Commitizen friendly](https://img.shields.io/badge/commitizen-friendly-brightgreen.svg)](http://commitizen.github.io/cz-cli/) 5 | 6 | 7 | Become a patreon 8 | 9 | 10 | A flexible and intuitive query builder for Neo4j and Cypher. 11 | Write queries in Javascript just as you would write them in Cypher. 12 | 13 | - Easy to use fluent interface 14 | - Support for streaming records using observables 15 | - Full Typescript declarations included in package 16 | 17 | ```javascript 18 | let results = await db.matchNode('user', 'User', { active: true }) 19 | .where({ 'user.age': greaterThan(18) }) 20 | .with('user') 21 | .create([ 22 | cypher.node('user', ''), 23 | cypher.relation('out', '', 'HasVehicle'), 24 | cypher.node('vehicle', 'Vehicle', { colour: 'red' }) 25 | ]) 26 | .ret(['user', 'vehicle']) 27 | .run(); 28 | 29 | // Results: 30 | // [{ 31 | // user: { 32 | // identity: 1234, 33 | // labels: [ 'User' ], 34 | // properties: { ... }, 35 | // }, 36 | // vehicle: { 37 | // identity: 4321, 38 | // labels: [ 'Vehicle' ], 39 | // properties: { ... }, 40 | // }, 41 | // }] 42 | ``` 43 | 44 | ## Contents 45 | 46 | - [Quick start](#quick-start) 47 | - [Installation](#installation) 48 | - [Importing](#importing) 49 | - [Connecting](#connecting) 50 | - [Querying](#querying) 51 | - [Processing](#processing) 52 | - [Documentation](#documentation) 53 | - [Contributing](#contributing) 54 | - [License](#license) 55 | 56 | ## Quick start 57 | 58 | ### Installation 59 | 60 | ``` 61 | npm install --save cypher-query-builder 62 | ``` 63 | or 64 | 65 | ``` 66 | yarn add cypher-query-builder 67 | ``` 68 | 69 | ### Importing 70 | 71 | CommonJS/Node 72 | 73 | ```javascript 74 | const cypher = require('cypher-query-builder'); 75 | // cypher.Connection 76 | // cypher.greaterThan 77 | // .... 78 | ``` 79 | 80 | ES6 81 | 82 | ```javascript 83 | import { Connection, greaterThan } from 'cypher-query-builder'; 84 | ``` 85 | 86 | ### Connecting 87 | 88 | ```javascript 89 | const cypher = require('cypher-query-builder'); 90 | 91 | // Make sure to include the protocol in the hostname 92 | let db = new cypher.Connection('bolt://localhost', { 93 | username: 'root', 94 | password: 'password', 95 | }); 96 | ``` 97 | 98 | Cypher query builder uses the official Neo4j Nodejs driver over the bolt 99 | protocol in the background so you can pass any values into connection that 100 | are accepted by that driver. 101 | 102 | ### Querying 103 | 104 | ES6 105 | 106 | ```javascript 107 | db.matchNode('projects', 'Project') 108 | .return('projects') 109 | .run() 110 | .then(function (results) { 111 | // Do something with results 112 | }); 113 | ``` 114 | 115 | ES2017 116 | 117 | ```javascript 118 | const results = await db.matchNode('projects', 'Project') 119 | .return('projects') 120 | .run(); 121 | ``` 122 | 123 | `run` will execute the query and return a promise. The results are in the 124 | _standardish_ Neo4j form an array of records: 125 | 126 | ```javascript 127 | const results = [ 128 | { 129 | projects: { 130 | // Internal Neo4j node id, don't rely on this to stay constant. 131 | identity: 1, 132 | 133 | // All labels attached to the node 134 | labels: [ 'Project' ], 135 | 136 | // Actual properties of the node. 137 | // Note that Neo4j numbers will automatically be converted to 138 | // Javascript numbers. This may cause issues because Neo4j can 139 | // store larger numbers than can be represented in Javascript. 140 | // This behaviour is currently in consideration and may change 141 | // in the future. 142 | properties: { name: 'Project 1' }, 143 | }, 144 | }, 145 | // ... 146 | ] 147 | ``` 148 | 149 | You can also use the `stream` method to download the results as an observable. 150 | 151 | ```javascript 152 | const results = db.matchNode('project', 'Project') 153 | .ret('project') 154 | .stream(); 155 | 156 | results.subscribe(row => console.log(row.project.properties.name)); 157 | ``` 158 | 159 | ### Processing 160 | 161 | To extract the results, you can use ES5 array methods or a library like lodash: 162 | 163 | ```javascript 164 | // Get all the project nodes (including their id, labels and properties). 165 | let projects = results.map(row => row.projects); 166 | 167 | // Get just the properties of the nodes 168 | let projectProps = results.map(row => row.projects.properties); 169 | ``` 170 | 171 | ## Documentation 172 | 173 | All the reference documentation can be found [here](http://jamesfer.me/cypher-query-builder). 174 | However, the two most useful pages are probably: 175 | 176 | - The [Connection](https://jamesfer.me/cypher-query-builder/classes/connection.html) class, for 177 | details on creating and using a connection. 178 | - The [Query](https://jamesfer.me/cypher-query-builder/classes/query.html) class, for details on 179 | all the available clauses, and building and running queries. 180 | 181 | ## Contributing 182 | 183 | Please feel free to submit any bugs or questions you may have in an 184 | [issue](https://github.com/jamesfer/cypher-query-builder/issues). I'm very open to discussing 185 | suggestions or new ideas so don't hesitate to reach out. 186 | 187 | Maintaining the library does take some time out of my schedule so if you'd like to show your 188 | appreciation please consider donating. Even the smallest amount is really encouraging. 189 | 190 | 191 | Become a patreon 192 | 193 | 194 | ## License 195 | 196 | MIT License 197 | 198 | Copyright (c) 2018 James Ferguson 199 | 200 | Permission is hereby granted, free of charge, to any person obtaining a copy 201 | of this software and associated documentation files (the "Software"), to deal 202 | in the Software without restriction, including without limitation the rights 203 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 204 | copies of the Software, and to permit persons to whom the Software is 205 | furnished to do so, subject to the following conditions: 206 | 207 | The above copyright notice and this permission notice shall be included in all 208 | copies or substantial portions of the Software. 209 | 210 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 211 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 212 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 213 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 214 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 215 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 216 | SOFTWARE. 217 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import babel from 'rollup-plugin-babel'; 3 | import commonjs from 'rollup-plugin-commonjs'; 4 | import resolve from 'rollup-plugin-node-resolve'; 5 | import typescript from 'rollup-plugin-typescript'; 6 | import { dependencies } from './package.json'; 7 | 8 | const configurations = [ 9 | { format: 'esm', target: 'es5' }, 10 | { format: 'esm', target: 'es2015' }, 11 | { format: 'cjs', target: 'es5' }, 12 | { format: 'cjs', target: 'es2015' }, 13 | ]; 14 | 15 | export default configurations.map(({ target, format }) => { 16 | const name = `${format}${target.replace('es', '')}.js`; 17 | return { 18 | input: path.resolve(__dirname, 'src', 'index.ts'), 19 | output: { 20 | format, 21 | file: path.resolve(__dirname, 'dist', name), 22 | sourcemap: true, 23 | }, 24 | plugins: [ 25 | resolve(), 26 | commonjs(), 27 | typescript({ target }), 28 | babel({ extensions: ['.ts'] }), 29 | ], 30 | external: id => id in dependencies 31 | || /^lodash/.test(id) 32 | || /^neo4j-driver/.test(id), 33 | }; 34 | }); 35 | -------------------------------------------------------------------------------- /scripts/build: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | kind="$1" 4 | case "$kind" in 5 | rollup) 6 | yarn rollup -c 7 | ;; 8 | declaration) 9 | tsc --project tsconfig.declaration.json --outDir dist/typings --declaration --emitDeclarationOnly 10 | ;; 11 | *) 12 | >&2 echo "Unknown build kind '$kind'. Expected rollup or declaration." 13 | exit 1 14 | ;; 15 | esac 16 | -------------------------------------------------------------------------------- /scripts/docs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | yarn typedoc \ 4 | src/builder.ts \ 5 | src/query.ts \ 6 | src/connection.ts \ 7 | src/clauses/index.ts \ 8 | src/clauses/where-comparators.ts \ 9 | src/clauses/where-operators.ts \ 10 | --mode file \ 11 | --theme minimal \ 12 | --out ./docs \ 13 | --excludeExternals \ 14 | --excludeProtected \ 15 | --excludePrivate \ 16 | --ignoreCompilerErrors 17 | -------------------------------------------------------------------------------- /scripts/lint: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | yarn tslint --project ./tsconfig.json 4 | -------------------------------------------------------------------------------- /scripts/report: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | yarn --silent nyc report --reporter=text-lcov \ 4 | | sed "s|/app|$(pwd)|" \ 5 | | ./node_modules/.bin/coveralls 6 | -------------------------------------------------------------------------------- /scripts/test: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | yarn --silent nyc \ 4 | --reporter=html \ 5 | --reporter=text-summary \ 6 | --reporter=lcov \ 7 | mocha \ 8 | src/**/*.spec.ts 9 | -------------------------------------------------------------------------------- /scripts/test-integration: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | NEO4J_URL=bolt://localhost 4 | NEO4J_USER=neo4j 5 | NEO4J_PASS=admin1234 6 | 7 | start-neo4j() { 8 | docker run \ 9 | -d \ 10 | --rm \ 11 | -p 7474:7474 \ 12 | -p 7687:7687 \ 13 | -e NEO4J_AUTH="$NEO4J_USER/$NEO4J_PASS" \ 14 | "neo4j:${NEO4J_VERSION-latest}" 15 | } 16 | 17 | run-tests() { 18 | yarn --silent nyc \ 19 | --reporter=html \ 20 | --reporter=text-summary \ 21 | --reporter=lcov \ 22 | mocha \ 23 | src/*.spec.ts \ 24 | src/**/*.spec.ts \ 25 | tests/*.test.ts \ 26 | tests/**/*.test.ts "$@" 27 | } 28 | 29 | # Start the neo4j docker container 30 | echo "Starting neo4j..." 31 | id=$(start-neo4j) || exit 1 32 | 33 | # Run the tests 34 | echo "Running tests..." 35 | NEO4J_URL="$NEO4J_URL" NEO4J_USER="$NEO4J_USER" NEO4J_PASS="$NEO4J_PASS" run-tests "$@" 36 | code="$?" 37 | 38 | # Stop the container 39 | echo "Stopping neo4j..." 40 | docker container stop "$id" > /dev/null 41 | 42 | # Exit with the same code as the tests 43 | exit "$code" 44 | -------------------------------------------------------------------------------- /scripts/validate: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | yarn --silent lint && yarn --silent build && yarn --silent test 4 | -------------------------------------------------------------------------------- /src/clause-collection.spec.ts: -------------------------------------------------------------------------------- 1 | import { values } from 'lodash'; 2 | import { Return, Unwind } from './clauses'; 3 | import { ClauseCollection } from './clause-collection'; 4 | import { expect } from '../test-setup'; 5 | 6 | describe('ClauseCollection', () => { 7 | describe('#addClause', () => { 8 | it('should add a clause to the internal list', () => { 9 | const clause = new Return('node'); 10 | const collection = new ClauseCollection(); 11 | 12 | collection.addClause(clause); 13 | 14 | expect(collection.getClauses()).includes(clause); 15 | }); 16 | 17 | it('should merge the parameter bag of the clause into its own', () => { 18 | const numbers = [1, 2, 3]; 19 | const clause = new Unwind(numbers, 'numbers'); 20 | const collection = new ClauseCollection(); 21 | 22 | collection.addClause(clause); 23 | 24 | expect(values(collection.getParams())).to.have.members([numbers]); 25 | }); 26 | }); 27 | 28 | describe('#build', () => { 29 | it('should join all clauses together', () => { 30 | const collection = new ClauseCollection(); 31 | const unwind = new Unwind([1, 2, 3], 'numbers'); 32 | collection.addClause(unwind); 33 | const ret = new Return('node'); 34 | collection.addClause(ret); 35 | 36 | expect(collection.build()).to.equal(`${unwind.build()}\n${ret.build()};`); 37 | }); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /src/clause-collection.ts: -------------------------------------------------------------------------------- 1 | import { map } from 'lodash'; 2 | import { Clause } from './clause'; 3 | 4 | export class ClauseCollection extends Clause { 5 | protected clauses: Clause[] = []; 6 | 7 | /** 8 | * Returns all clauses in this collection. 9 | * @returns {Clause[]} 10 | */ 11 | getClauses(): Clause[] { 12 | return this.clauses; 13 | } 14 | 15 | /** 16 | * Adds a clause to the child list. 17 | * @param {Clause} clause 18 | */ 19 | addClause(clause: Clause) { 20 | clause.useParameterBag(this.parameterBag); 21 | this.clauses.push(clause); 22 | } 23 | 24 | /** 25 | * @inheritDoc 26 | */ 27 | build() { 28 | return `${map(this.clauses, s => s.build()).join('\n')};`; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/clause.spec.ts: -------------------------------------------------------------------------------- 1 | import { Clause } from './clause'; 2 | import { Where } from './clauses'; 3 | import { expect } from 'chai'; 4 | import { ParameterBag } from './parameter-bag'; 5 | 6 | describe('Clause', () => { 7 | describe('interpolate', () => { 8 | it('should correctly inline parameters that share a prefix', () => { 9 | class SpecialClause extends Clause { 10 | constructor(public query: string) { 11 | super(); 12 | } 13 | 14 | build() { 15 | return this.query; 16 | } 17 | } 18 | 19 | const bag = new ParameterBag(); 20 | bag.addParam('abc', 'param'); 21 | bag.addParam('def', 'paramLong'); 22 | 23 | const clause = new SpecialClause('param = $paramLong'); 24 | clause.useParameterBag(bag); 25 | expect(clause.interpolate()).to.equal("param = 'def'"); 26 | }); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /src/clause.ts: -------------------------------------------------------------------------------- 1 | import { stringifyValue } from './utils'; 2 | import { ParameterContainer } from './parameter-container'; 3 | import { Dictionary } from 'lodash'; 4 | 5 | export type QueryObject = { 6 | query: string; 7 | params: Dictionary 8 | }; 9 | 10 | export abstract class Clause extends ParameterContainer { 11 | /** 12 | * Turns the clause into a query string. 13 | * @return {string} Partial query string. 14 | */ 15 | abstract build(): string; 16 | 17 | /** 18 | * Turns the clause into a query string. 19 | * @return {string} Partial query string. 20 | */ 21 | toString(): string { 22 | return this.build(); 23 | } 24 | 25 | /** 26 | * Turns the clause into a query object. 27 | * @return {object} Query object with two parameters: query and params. 28 | */ 29 | buildQueryObject(): QueryObject { 30 | return { 31 | query: this.build(), 32 | params: this.getParams(), 33 | }; 34 | } 35 | 36 | /** 37 | * Turns the clause into a query string with parameters 38 | * interpolated into the string. For debugging purposes only. 39 | * @return {string} 40 | */ 41 | interpolate(): string { 42 | let query = this.build(); 43 | const params = this.getParams(); 44 | for (const name in params) { 45 | const pattern = new RegExp(`\\$${name}(?![a-zA-Z0-9_])`, 'g'); 46 | query = query.replace(pattern, stringifyValue(params[name])); 47 | } 48 | return query; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/clauses/create.spec.ts: -------------------------------------------------------------------------------- 1 | import { Create } from './create'; 2 | import { expect } from 'chai'; 3 | import { NodePattern } from './node-pattern'; 4 | 5 | describe('Create', () => { 6 | describe('#build', () => { 7 | it('should start with CREATE', () => { 8 | const create = new Create(new NodePattern('node')); 9 | expect(create.build()).to.equal('CREATE (node)'); 10 | }); 11 | 12 | it('should start with CREATE UNIQUE when unique option is set to true', () => { 13 | const create = new Create(new NodePattern('node'), { unique: true }); 14 | expect(create.build()).to.equal('CREATE UNIQUE (node)'); 15 | }); 16 | 17 | it('should not use expanded conditions', () => { 18 | const create = new Create(new NodePattern('node', { 19 | firstName: 'test', 20 | lastName: 'test', 21 | })); 22 | expect(create.build()).to.equal('CREATE (node $conditions)'); 23 | }); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/clauses/create.ts: -------------------------------------------------------------------------------- 1 | import { PatternClause, PatternCollection } from './pattern-clause'; 2 | 3 | export interface CreateOptions { 4 | unique?: boolean; 5 | } 6 | 7 | export class Create extends PatternClause { 8 | constructor(patterns: PatternCollection, protected options: CreateOptions = {}) { 9 | super(patterns, { useExpandedConditions: false }); 10 | } 11 | 12 | build() { 13 | const unique = this.options.unique ? ' UNIQUE' : ''; 14 | return `CREATE${unique} ${super.build()}`; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/clauses/delete.spec.ts: -------------------------------------------------------------------------------- 1 | import { Delete } from './delete'; 2 | import { expect } from 'chai'; 3 | 4 | describe('Delete', () => { 5 | describe('#build', () => { 6 | it('should start with DELETE', () => { 7 | const query = new Delete('node'); 8 | expect(query.build()).to.equal('DELETE node'); 9 | }); 10 | 11 | it('should start with DELETE when options are empty', () => { 12 | const query = new Delete('node', {}); 13 | expect(query.build()).to.equal('DELETE node'); 14 | }); 15 | 16 | it('should start with DETACH DELETE when detach is true', () => { 17 | const query = new Delete('node', { detach: true }); 18 | expect(query.build()).to.equal('DETACH DELETE node'); 19 | }); 20 | 21 | it('should support an array of variables', () => { 22 | const query = new Delete(['node1', 'node2']); 23 | expect(query.build()).to.equal('DELETE node1, node2'); 24 | }); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /src/clauses/delete.ts: -------------------------------------------------------------------------------- 1 | import { Many, castArray } from 'lodash'; 2 | import { Clause } from '../clause'; 3 | 4 | export interface DeleteOptions { 5 | detach?: boolean; 6 | } 7 | 8 | export class Delete extends Clause { 9 | variables: string[]; 10 | 11 | constructor( 12 | variables: Many, 13 | protected options: DeleteOptions = { }, 14 | ) { 15 | super(); 16 | this.variables = castArray(variables); 17 | } 18 | 19 | build() { 20 | const detach = this.options.detach ? 'DETACH ' : ''; 21 | return `${detach}DELETE ${this.variables.join(', ')}`; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/clauses/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { node, relation } from './index'; 2 | import { expect } from 'chai'; 3 | import { NodePattern } from './node-pattern'; 4 | import { RelationPattern } from './relation-pattern'; 5 | 6 | describe('node', () => { 7 | it('should create a node pattern', () => { 8 | const query = node('node', 'Label', { prop: 'prop' }); 9 | expect(query).to.be.instanceOf(NodePattern); 10 | expect(query.build()).to.equal('(node:Label { prop: $prop })'); 11 | }); 12 | }); 13 | 14 | describe('relation', () => { 15 | it('should create a relation pattern', () => { 16 | const query = relation('out', 'rel', 'Label', { prop: 'prop' }); 17 | expect(query).to.be.instanceOf(RelationPattern); 18 | expect(query.build()).to.equal('-[rel:Label { prop: $prop }]->'); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /src/clauses/index.ts: -------------------------------------------------------------------------------- 1 | import { Dictionary, Many } from 'lodash'; 2 | import { NodePattern } from './node-pattern'; 3 | import { RelationDirection, RelationPattern } from './relation-pattern'; 4 | import { PathLength } from '../utils'; 5 | 6 | export { Create } from './create'; 7 | export { NodePattern } from './node-pattern'; 8 | export { With } from './with'; 9 | export { Unwind } from './unwind'; 10 | export { Delete } from './delete'; 11 | export { Set } from './set'; 12 | export { RelationPattern } from './relation-pattern'; 13 | export { Match } from './match'; 14 | export { Remove } from './remove'; 15 | export { Return } from './return'; 16 | export { Skip } from './skip'; 17 | export { Limit } from './limit'; 18 | export { Where } from './where'; 19 | export { Raw } from './raw'; 20 | export { OrderBy } from './order-by'; 21 | export { Merge } from './merge'; 22 | export { OnMatch } from './on-match'; 23 | export { OnCreate } from './on-create'; 24 | export { and, or, xor, not, operators } from './where-operators'; 25 | export { 26 | equals, 27 | greaterThan, 28 | greaterEqualTo, 29 | lessThan, 30 | lessEqualTo, 31 | startsWith, 32 | endsWith, 33 | contains, 34 | inArray, 35 | hasLabel, 36 | exists, 37 | between, 38 | isNull, 39 | regexp, 40 | comparisions, 41 | } from './where-comparators'; 42 | 43 | /** 44 | * Creates a node pattern like `(parent:Person { name: 'Gwenn' })`. 45 | * 46 | * All of the arguments are optional and most of the time you can supply only 47 | * the ones you want, assuming you keep the order the same of course. 48 | * 49 | * Use the following signatures as a reference: 50 | * 51 | * ```typescript 52 | * node(conditions: Dictionary) 53 | * node(labels: string[], conditions?: Dictionary) 54 | * node(name: string, conditions?: Dictionary) 55 | * node(name: string, labels?: string | string[], conditions?: Dictionary) 56 | * ``` 57 | * *Note that labels must be an array when it is the first argument.* 58 | * 59 | * 60 | * 61 | * Some examples 62 | * 63 | * ```typescript 64 | * node() 65 | * // () 66 | * 67 | * node('parent') 68 | * // (parent) 69 | * 70 | * node('parent', 'Person') 71 | * // (parent:Person) 72 | * 73 | * node([ 'Person' ]) 74 | * // (:Person) 75 | * 76 | * node('parent', [ 'Person', 'Adult' ]) 77 | * // (parent:Person:Adult) 78 | * 79 | * node({ name: 'Gwenn' }) 80 | * // ({ name: 'Gwenn' }) 81 | * 82 | * node('parent', { name: 'Gwenn' }) 83 | * // (parent { name: 'Gwenn' }) 84 | * 85 | * node([ 'Person' ], { name: 'Gwenn' }) 86 | * // (:Person { name: 'Gwenn' }) 87 | * 88 | * node('parent', 'Person', { name: 'Gwenn' }) 89 | * // (parent:Person { name: 'Gwenn' }) 90 | * ``` 91 | * 92 | * For more details on node patterns see the cypher 93 | * [docs]{@link 94 | * https://neo4j.com/docs/developer-manual/current/cypher/syntax/patterns/#cypher-pattern-node} 95 | * 96 | * @param {_.Many | _.Dictionary} name 97 | * @param {_.Many | _.Dictionary} labels 98 | * @param {_.Dictionary} conditions A dictionary of conditions to attach 99 | * to the node. These are stored as parameters so there is no need to worry 100 | * about escaping. 101 | * @returns {NodePattern} An object representing the node pattern. 102 | */ 103 | export function node( 104 | name?: Many | Dictionary, 105 | labels?: Many | Dictionary, 106 | conditions?: Dictionary, 107 | ) { 108 | return new NodePattern(name, labels, conditions); 109 | } 110 | 111 | // Need to disable line length because there is a long link in the documentation 112 | /* tslint:disable:max-line-length */ 113 | /** 114 | * Creates a relation pattern like `-[rel:FriendsWith { active: true }]->`. 115 | * 116 | * The only required argument is direction. All other arguments are optional and all combinations of 117 | * them are valid. The only exception is that when labels is the first argument after direction, it 118 | * must be an array, otherwise it will be interpreted as the relation name. 119 | * 120 | * Some examples 121 | * 122 | * ```typescript 123 | * relation('either') 124 | * // -- 125 | * 126 | * relation('out', 'rel') 127 | * // -[rel]-> 128 | * 129 | * relation('out', 'rel', 'FriendsWith') 130 | * // -[rel:FriendsWith]-> 131 | * 132 | * relation('in', [ 'FriendsWith', 'RelatedTo' ]) 133 | * // <-[:FriendsWith|RelatedTo]- 134 | * // Note that this will match a relation with either the FriendsWith label or 135 | * // the RelatedTo label. You cannot use this syntax when creating relations. 136 | * 137 | * relation('in', [4, 10]) 138 | * // <-[*4..10]- 139 | * 140 | * relation('in', { active: true }) 141 | * // <-[{ active: true }] 142 | * 143 | * relation('in', 'rel', { active: true }) 144 | * // <-[rel { active: true }]- 145 | * 146 | * relation('either', [ 'FriendsWith' ], { active: true }) 147 | * // -[:FriendsWith { active: true }]- 148 | * 149 | * relation('either', 'rel', 'FriendsWith', { active: true }, 3) 150 | * // -[rel:FriendsWith*3 { active: true }]- 151 | * 152 | * relation('either', 'rel', 'FriendsWith', { active: true }, [ 3 ]) 153 | * // -[rel:FriendsWith*3.. { active: true }]- 154 | * 155 | * relation('either', 'rel', 'FriendsWith', { active: true }, [ 3, 5 ]) 156 | * // -[rel:FriendsWith*3..5 { active: true }]- 157 | * 158 | * relation('either', 'rel', 'FriendsWith', { active: true }, '*') 159 | * // -[rel:FriendsWith* { active: true }]- 160 | * ``` 161 | * 162 | * For more details on relation patterns see the cypher 163 | * [docs]{@link 164 | * https://neo4j.com/docs/developer-manual/current/cypher/syntax/patterns/#cypher-pattern-relationship}. 165 | * 166 | * @param dir Direction of the relation. `in` means to the left, `out` means to 167 | * the right and `either` means no direction. 168 | * @param {_.Many | _.Dictionary} name 169 | * @param {_.Many | _.Dictionary} labels 170 | * @param {_.Dictionary} conditions 171 | * @param length Length of the relation for flexible length paths. Can be the 172 | * string `'*'` to represent any length, a single number `3` to represent the 173 | * maximum length of the path, or an array of two numbers which represent the 174 | * minimum and maximum length of the path. When passing an array, the second 175 | * number is optional, see the examples above. 176 | * @returns {RelationPattern} An object representing the relation pattern. 177 | */ 178 | /* tslint:disable:max-line-length */ 179 | export function relation( 180 | dir: RelationDirection, 181 | name?: Many | Dictionary | PathLength, 182 | labels?: Many | Dictionary | PathLength, 183 | conditions?: Dictionary | PathLength, 184 | length?: PathLength, 185 | ) { 186 | return new RelationPattern(dir, name, labels, conditions, length); 187 | } 188 | -------------------------------------------------------------------------------- /src/clauses/limit.spec.ts: -------------------------------------------------------------------------------- 1 | import neo4jDriver from 'neo4j-driver'; 2 | import { expect } from 'chai'; 3 | import { Limit } from './limit'; 4 | 5 | describe('Limit', () => { 6 | describe('#build', () => { 7 | it('should add a produce a limit clause', () => { 8 | const query = new Limit(10); 9 | expect(query.build()).to.equal('LIMIT $limitCount'); 10 | expect(query.getParams()).to.eql({ limitCount: neo4jDriver.int(10) }); 11 | }); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /src/clauses/limit.ts: -------------------------------------------------------------------------------- 1 | import neo4jDriver from 'neo4j-driver'; 2 | import { Clause } from '../clause'; 3 | import { Parameter } from '../parameter-bag'; 4 | 5 | export class Limit extends Clause { 6 | protected amountParam: Parameter; 7 | 8 | constructor(public amount: number) { 9 | super(); 10 | this.amountParam = this.addParam(neo4jDriver.int(amount), 'limitCount'); 11 | } 12 | 13 | build() { 14 | return `LIMIT ${this.amountParam}`; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/clauses/match.spec.ts: -------------------------------------------------------------------------------- 1 | import { Match } from './match'; 2 | import { NodePattern } from './node-pattern'; 3 | import { expect } from 'chai'; 4 | 5 | describe('Match', () => { 6 | describe('#build', () => { 7 | it('should start with MATCH', () => { 8 | const create = new Match(new NodePattern('node')); 9 | expect(create.build()).to.equal('MATCH (node)'); 10 | }); 11 | 12 | it('should start with OPTIONAL MATCH if optional is true', () => { 13 | const create = new Match(new NodePattern('node'), { optional: true }); 14 | expect(create.build()).to.equal('OPTIONAL MATCH (node)'); 15 | }); 16 | 17 | it('should use expanded conditions', () => { 18 | const create = new Match(new NodePattern('node', { 19 | firstName: 'test', 20 | lastName: 'test', 21 | })); 22 | const clauseString = 'MATCH (node { firstName: $firstName, lastName: $lastName })'; 23 | expect(create.build()).to.equal(clauseString); 24 | }); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /src/clauses/match.ts: -------------------------------------------------------------------------------- 1 | import { PatternClause, PatternCollection } from './pattern-clause'; 2 | 3 | export interface MatchOptions { 4 | optional?: boolean; 5 | } 6 | 7 | export class Match extends PatternClause { 8 | constructor( 9 | patterns: PatternCollection, 10 | protected options: MatchOptions = { optional: false }, 11 | ) { 12 | super(patterns, { useExpandedConditions: true }); 13 | } 14 | 15 | build() { 16 | let str = 'MATCH '; 17 | if (this.options.optional) { 18 | str = `OPTIONAL ${str}`; 19 | } 20 | return str + super.build(); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/clauses/merge.spec.ts: -------------------------------------------------------------------------------- 1 | import { Merge } from './merge'; 2 | import { NodePattern } from './node-pattern'; 3 | import { expect } from 'chai'; 4 | 5 | describe('Merge', () => { 6 | describe('#build', () => { 7 | it('should start with MERGE', () => { 8 | const create = new Merge(new NodePattern('node')); 9 | expect(create.build()).to.equal('MERGE (node)'); 10 | }); 11 | 12 | it('should use expanded conditions', () => { 13 | const params = { 14 | firstName: 'test', 15 | lastName: 'test', 16 | }; 17 | const create = new Merge(new NodePattern('node', params)); 18 | const clauseString = 'MERGE (node { firstName: $firstName, lastName: $lastName })'; 19 | expect(create.build()).to.equal(clauseString); 20 | expect(create.getParams()).to.deep.equal(params); 21 | }); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/clauses/merge.ts: -------------------------------------------------------------------------------- 1 | import { PatternClause, PatternCollection } from './pattern-clause'; 2 | 3 | export class Merge extends PatternClause { 4 | constructor( 5 | patterns: PatternCollection, 6 | ) { 7 | super(patterns, { useExpandedConditions: true }); 8 | } 9 | 10 | build() { 11 | return `MERGE ${super.build()}`; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/clauses/node-pattern.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { keys, values } from 'lodash'; 3 | import { NodePattern } from './node-pattern'; 4 | 5 | describe('Node', () => { 6 | describe('#build', () => { 7 | const conditions = { name: 'Steve', active: true }; 8 | 9 | it('should build a node pattern with a variable name', () => { 10 | const node = new NodePattern('person'); 11 | const queryObj = node.buildQueryObject(); 12 | expect(queryObj.query).to.equal('(person)'); 13 | expect(queryObj.params).to.be.empty; 14 | }); 15 | 16 | it('should build a node pattern with a label', () => { 17 | const node = new NodePattern('person', 'Person'); 18 | const queryObj = node.buildQueryObject(); 19 | expect(queryObj.query).to.equal('(person:Person)'); 20 | expect(queryObj.params).to.be.empty; 21 | }); 22 | 23 | it('should build a node pattern with multiple labels', () => { 24 | const node = new NodePattern('person', ['Person', 'Staff', 'Female']); 25 | const queryObj = node.buildQueryObject(); 26 | expect(queryObj.query).to.equal('(person:Person:Staff:Female)'); 27 | expect(queryObj.params).to.be.empty; 28 | }); 29 | 30 | it('should build a node pattern with just labels', () => { 31 | const node = new NodePattern(['Person', 'Staff', 'Female']); 32 | const queryObj = node.buildQueryObject(); 33 | expect(queryObj.query).to.equal('(:Person:Staff:Female)'); 34 | expect(queryObj.params).to.be.empty; 35 | }); 36 | 37 | it('should build a node pattern with just conditions', () => { 38 | const node = new NodePattern(conditions); 39 | const queryObj = node.buildQueryObject(); 40 | 41 | expect(queryObj.query).to.equal('({ name: $name, active: $active })'); 42 | expect(keys(queryObj.params)).to.have.length(2); 43 | expect(values(queryObj.params)).to.have.members(['Steve', true]); 44 | }); 45 | 46 | it('should build a node pattern with a name and conditions', () => { 47 | const node = new NodePattern('person', conditions); 48 | const queryObj = node.buildQueryObject(); 49 | 50 | expect(queryObj.query).to.equal('(person { name: $name, active: $active })'); 51 | expect(keys(queryObj.params)).to.have.length(2); 52 | expect(values(queryObj.params)).to.have.members(['Steve', true]); 53 | }); 54 | 55 | it('should build a node pattern with labels and conditions', () => { 56 | const node = new NodePattern(['Person', 'Staff'], conditions); 57 | const queryObj = node.buildQueryObject(); 58 | 59 | expect(queryObj.query).to.equal('(:Person:Staff { name: $name, active: $active })'); 60 | expect(keys(queryObj.params)).to.have.length(2); 61 | expect(values(queryObj.params)).to.have.members(['Steve', true]); 62 | }); 63 | 64 | it('should build a node pattern with condensed conditions', () => { 65 | const node = new NodePattern('person', [], conditions); 66 | node.setExpandedConditions(false); 67 | const queryObj = node.buildQueryObject(); 68 | 69 | expect(queryObj.query).to.equal('(person $conditions)'); 70 | expect(keys(queryObj.params)).to.have.length(1); 71 | expect(values(queryObj.params)).to.have.members([conditions]); 72 | }); 73 | 74 | it('should build a node pattern with expanded conditions', () => { 75 | const node = new NodePattern('person', [], conditions); 76 | const queryObj = node.buildQueryObject(); 77 | 78 | expect(queryObj.query).to.equal('(person { name: $name, active: $active })'); 79 | expect(keys(queryObj.params)).to.have.length(2); 80 | expect(values(queryObj.params)).to.have.members(['Steve', true]); 81 | }); 82 | 83 | it('should build a complete node pattern', () => { 84 | const node = new NodePattern( 85 | 'person', 86 | ['Person', 'Staff', 'Female'], 87 | conditions, 88 | ); 89 | let queryObj = node.buildQueryObject(); 90 | 91 | const pattern = '(person:Person:Staff:Female { name: $name, active: $active })'; 92 | expect(queryObj.query).to.equal(pattern); 93 | expect(keys(queryObj.params)).to.have.length(2); 94 | expect(values(queryObj.params)).to.have.members(['Steve', true]); 95 | 96 | node.setExpandedConditions(false); 97 | queryObj = node.buildQueryObject(); 98 | expect(queryObj.query).to.equal('(person:Person:Staff:Female $conditions)'); 99 | expect(keys(queryObj.params)).to.have.length(1); 100 | expect(values(queryObj.params)).to.have.members([conditions]); 101 | }); 102 | }); 103 | }); 104 | -------------------------------------------------------------------------------- /src/clauses/node-pattern.ts: -------------------------------------------------------------------------------- 1 | import { Dictionary, Many, trim } from 'lodash'; 2 | import { Pattern } from './pattern'; 3 | 4 | export class NodePattern extends Pattern { 5 | constructor( 6 | name?: Many | Dictionary, 7 | labels?: Many | Dictionary, 8 | conditions?: Dictionary, 9 | ) { 10 | super(name, labels, conditions); 11 | } 12 | 13 | build() { 14 | let query = this.getNameString(); 15 | query += this.getLabelsString(); 16 | query += ` ${this.getConditionsParamString()}`; 17 | return `(${trim(query)})`; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/clauses/on-create.spec.ts: -------------------------------------------------------------------------------- 1 | import { OnCreate } from './on-create'; 2 | import { Set } from './set'; 3 | import { expect } from '../../test-setup'; 4 | 5 | describe('OnCreate', () => { 6 | it('should prefix ON CREATE', () => { 7 | const clause = new OnCreate(new Set({ labels: { a: ['Label'] } })); 8 | expect(clause.build()).to.equal('ON CREATE SET a:Label'); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /src/clauses/on-create.ts: -------------------------------------------------------------------------------- 1 | import { Set } from './set'; 2 | import { Clause } from '../clause'; 3 | 4 | export class OnCreate extends Clause { 5 | constructor(protected clause: Set) { 6 | super(); 7 | clause.useParameterBag(this.parameterBag); 8 | } 9 | 10 | build() { 11 | return `ON CREATE ${this.clause.build()}`; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/clauses/on-match.spec.ts: -------------------------------------------------------------------------------- 1 | import { Set } from './set'; 2 | import { expect } from '../../test-setup'; 3 | import { OnMatch } from './on-match'; 4 | 5 | describe('OnMatch', () => { 6 | it('should prefix ON MATCH', () => { 7 | const clause = new OnMatch(new Set({ labels: { a: ['Label'] } })); 8 | expect(clause.build()).to.equal('ON MATCH SET a:Label'); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /src/clauses/on-match.ts: -------------------------------------------------------------------------------- 1 | import { Set } from './set'; 2 | import { Clause } from '../clause'; 3 | 4 | export class OnMatch extends Clause { 5 | constructor(protected clause: Set) { 6 | super(); 7 | clause.useParameterBag(this.parameterBag); 8 | } 9 | 10 | build() { 11 | return `ON MATCH ${this.clause.build()}`; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/clauses/order-by.spec.ts: -------------------------------------------------------------------------------- 1 | import { OrderBy } from './order-by'; 2 | import { expect } from 'chai'; 3 | 4 | describe('OrderBy', () => { 5 | it('should start with ORDER BY', () => { 6 | const query = new OrderBy('node.prop'); 7 | expect(query.build()).to.equal('ORDER BY node.prop'); 8 | }); 9 | 10 | it('should support a direction arg', () => { 11 | let query = new OrderBy('node.prop', 'DESC'); 12 | expect(query.build()).to.equal('ORDER BY node.prop DESC'); 13 | query = new OrderBy('node.prop', 'DESCENDING'); 14 | expect(query.build()).to.equal('ORDER BY node.prop DESC'); 15 | query = new OrderBy('node.prop', 'ASC'); 16 | expect(query.build()).to.equal('ORDER BY node.prop'); 17 | query = new OrderBy('node.prop', 'ASCENDING'); 18 | expect(query.build()).to.equal('ORDER BY node.prop'); 19 | }); 20 | 21 | it('should support a case-insensitive direction arg', () => { 22 | let query = new OrderBy('node.prop', 'desc'); 23 | expect(query.build()).to.equal('ORDER BY node.prop DESC'); 24 | query = new OrderBy('node.prop', 'descending'); 25 | expect(query.build()).to.equal('ORDER BY node.prop DESC'); 26 | query = new OrderBy('node.prop', 'asc'); 27 | expect(query.build()).to.equal('ORDER BY node.prop'); 28 | query = new OrderBy('node.prop', 'ascending'); 29 | expect(query.build()).to.equal('ORDER BY node.prop'); 30 | }); 31 | 32 | it('should support a boolean direction arg', () => { 33 | let query = new OrderBy('node.prop', false); 34 | expect(query.build()).to.equal('ORDER BY node.prop'); 35 | query = new OrderBy('node.prop', true); 36 | expect(query.build()).to.equal('ORDER BY node.prop DESC'); 37 | }); 38 | 39 | it('should support null and undefined as directions', () => { 40 | let query = new OrderBy('node.prop', null); 41 | expect(query.build()).to.equal('ORDER BY node.prop'); 42 | query = new OrderBy('node.prop', undefined); 43 | expect(query.build()).to.equal('ORDER BY node.prop'); 44 | }); 45 | 46 | it('should support multiple order columns', () => { 47 | const query = new OrderBy(['node.prop1', 'node.prop2']); 48 | expect(query.build()).to.equal('ORDER BY node.prop1, node.prop2'); 49 | }); 50 | 51 | it('should support multiple order columns with a default direction', () => { 52 | const query = new OrderBy(['node.prop1', 'node.prop2'], 'DESC'); 53 | expect(query.build()).to.equal('ORDER BY node.prop1 DESC, node.prop2 DESC'); 54 | }); 55 | 56 | it('should support multiple order columns with directions', () => { 57 | const query = new OrderBy({ 58 | 'node.prop1': 'DESC', 59 | 'node.prop2': 'ASC', 60 | 'node.prop3': true, 61 | }); 62 | expect(query.build()).to.equal('ORDER BY node.prop1 DESC, node.prop2, node.prop3 DESC'); 63 | }); 64 | 65 | it('should support multiple order columns with directions using the array syntax', () => { 66 | const query = new OrderBy([ 67 | ['node.prop1', 'DESC'], 68 | 'node.prop2', 69 | ['node.prop3', true], 70 | ]); 71 | expect(query.build()).to.equal('ORDER BY node.prop1 DESC, node.prop2, node.prop3 DESC'); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /src/clauses/order-by.ts: -------------------------------------------------------------------------------- 1 | import { map, isString, isArray, Dictionary, trim } from 'lodash'; 2 | import { Clause } from '../clause'; 3 | 4 | export type Direction = boolean 5 | | 'DESC' 6 | | 'desc' 7 | | 'DESCENDING' 8 | | 'descending' 9 | | 'ASC' 10 | | 'asc' 11 | | 'ASCENDING' 12 | | 'ascending' 13 | | null 14 | | undefined; 15 | export type InternalDirection = 'DESC' | ''; 16 | export type OrderConstraint = [string, Direction] | [string]; 17 | export type InternalOrderConstraint = { field: string, direction: InternalDirection }; 18 | export type OrderConstraints = Dictionary; 19 | 20 | export class OrderBy extends Clause { 21 | constraints: InternalOrderConstraint[]; 22 | 23 | constructor(fields: string | (string | OrderConstraint)[] | OrderConstraints, dir?: Direction) { 24 | super(); 25 | const direction = OrderBy.normalizeDirection(dir); 26 | 27 | if (isString(fields)) { 28 | this.constraints = [{ direction, field: fields }]; 29 | } else if (isArray(fields)) { 30 | this.constraints = map(fields, (field): InternalOrderConstraint => { 31 | if (!isArray(field)) { 32 | return { field, direction }; 33 | } 34 | const fieldDirection = field[1] ? OrderBy.normalizeDirection(field[1]) : direction; 35 | return { field: field[0], direction: fieldDirection }; 36 | }); 37 | } else { 38 | this.constraints = map(fields, (fieldDirection, field) => { 39 | return { field, direction: OrderBy.normalizeDirection(fieldDirection) }; 40 | }); 41 | } 42 | } 43 | 44 | build() { 45 | const constraints = map(this.constraints, ({ field, direction }) => { 46 | return trim(`${field} ${direction}`); 47 | }); 48 | return `ORDER BY ${constraints.join(', ')}`; 49 | } 50 | 51 | private static normalizeDirection(dir?: Direction | string): InternalDirection { 52 | const upperDir = typeof dir === 'string' ? dir.toUpperCase() : dir; 53 | const isDescending = upperDir === 'DESC' || upperDir === 'DESCENDING' || upperDir === true; 54 | return isDescending ? 'DESC' : ''; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/clauses/pattern-clause.spec.ts: -------------------------------------------------------------------------------- 1 | import { PatternClause } from './pattern-clause'; 2 | import { NodePattern } from './node-pattern'; 3 | import { expect } from 'chai'; 4 | 5 | describe('PatternClauses', () => { 6 | describe('#build', () => { 7 | it('should accept a single pattern', () => { 8 | const pattern = new PatternClause(new NodePattern('a', [])); 9 | expect(pattern.build()).to.equal('(a)'); 10 | expect(pattern.getParams()).to.be.empty; 11 | }); 12 | 13 | it('should combine pattern sections with no delimiter', () => { 14 | const pattern = new PatternClause([ 15 | new NodePattern('a', []), 16 | new NodePattern('b', []), 17 | new NodePattern('c', []), 18 | ]); 19 | expect(pattern.build()).to.equal('(a)(b)(c)'); 20 | expect(pattern.getParams()).to.be.empty; 21 | }); 22 | 23 | it('should combine multiple patterns with a comma', () => { 24 | const pattern = new PatternClause([ 25 | [ 26 | new NodePattern('a', []), 27 | new NodePattern('b', []), 28 | new NodePattern('c', []), 29 | ], 30 | [ 31 | new NodePattern('d', []), 32 | new NodePattern('e', []), 33 | new NodePattern('f', []), 34 | ], 35 | ]); 36 | expect(pattern.build()).to.equal('(a)(b)(c), (d)(e)(f)'); 37 | expect(pattern.getParams()).to.be.empty; 38 | }); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /src/clauses/pattern-clause.ts: -------------------------------------------------------------------------------- 1 | import { reduce, map, assign, castArray, isArray } from 'lodash'; 2 | import { Pattern } from './pattern'; 3 | import { Clause } from '../clause'; 4 | 5 | export interface PatternOptions { 6 | useExpandedConditions?: boolean; 7 | } 8 | 9 | export type PatternCollection = Pattern | Pattern[] | Pattern[][]; 10 | 11 | export class PatternClause extends Clause { 12 | protected patterns: Pattern[][]; 13 | 14 | constructor( 15 | patterns: PatternCollection, 16 | options: PatternOptions = { useExpandedConditions: false }, 17 | ) { 18 | super(); 19 | const defaultOptions = { 20 | useExpandedConditions: true, 21 | }; 22 | const { useExpandedConditions } = assign(defaultOptions, options); 23 | 24 | // Ensure patterns is a two dimensional array. 25 | const arr = castArray(patterns); 26 | this.patterns = (isArray(arr[0]) ? arr : [arr]) as Pattern[][]; 27 | 28 | // Add child patterns as clauses 29 | this.patterns.forEach(arr => arr.forEach((pat) => { 30 | pat.setExpandedConditions(useExpandedConditions); 31 | pat.useParameterBag(this.parameterBag); 32 | })); 33 | } 34 | 35 | build() { 36 | const patternStrings = map(this.patterns, (pattern) => { 37 | return reduce(pattern, (str, clause) => str + clause.build(), ''); 38 | }); 39 | return patternStrings.join(', '); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/clauses/pattern.spec.ts: -------------------------------------------------------------------------------- 1 | import { Pattern } from './pattern'; 2 | import { expect } from 'chai'; 3 | 4 | class ConcretePattern extends Pattern { 5 | build() { 6 | return ''; 7 | } 8 | } 9 | 10 | describe('Pattern', () => { 11 | it('should accept no arguments', () => { 12 | const pattern = new ConcretePattern(); 13 | expect(pattern.getLabelsString()).to.equal(''); 14 | expect(pattern.getNameString()).to.equal(''); 15 | }); 16 | 17 | it('should accept just a name', () => { 18 | const pattern = new ConcretePattern('label'); 19 | expect(pattern.getLabelsString()).to.equal(''); 20 | expect(pattern.getNameString()).to.equal('label'); 21 | }); 22 | 23 | it('should accept just an array of labels', () => { 24 | const pattern = new ConcretePattern(['label1', 'label2']); 25 | expect(pattern.getLabelsString()).to.equal(':label1:label2'); 26 | expect(pattern.getLabelsString(true)).to.equal(':label1|label2'); 27 | expect(pattern.getNameString()).to.equal(''); 28 | }); 29 | 30 | it('should accept just conditions', () => { 31 | const pattern = new ConcretePattern({ key: 'value' }); 32 | expect(pattern.getNameString()).to.equal(''); 33 | expect(pattern.getConditionsParamString()).to.equal('{ key: $key }'); 34 | expect(pattern.getLabelsString()).to.equal(''); 35 | }); 36 | 37 | it('should accept a name and labels', () => { 38 | const singleLabelPattern = new ConcretePattern('name', 'label'); 39 | expect(singleLabelPattern.getLabelsString()).to.equal(':label'); 40 | expect(singleLabelPattern.getNameString()).to.equal('name'); 41 | 42 | const multiLabelPattern = new ConcretePattern('name', ['label1', 'label2']); 43 | expect(multiLabelPattern.getLabelsString()).to.equal(':label1:label2'); 44 | expect(multiLabelPattern.getNameString()).to.equal('name'); 45 | }); 46 | 47 | it('should accept an empty array of labels', () => { 48 | const emptyLabelPattern = new ConcretePattern('name', []); 49 | expect(emptyLabelPattern.getLabelsString()).to.equal(''); 50 | expect(emptyLabelPattern.getNameString()).to.equal('name'); 51 | }); 52 | 53 | it('should accept a name and conditions', () => { 54 | const conditions = { key: 'value' }; 55 | const conditionsString = '{ key: $key }'; 56 | 57 | const singleLabelPattern = new ConcretePattern('label', conditions); 58 | expect(singleLabelPattern.getLabelsString()).to.equal(''); 59 | expect(singleLabelPattern.getNameString()).to.equal('label'); 60 | expect(singleLabelPattern.getConditionsParamString()) 61 | .to.equal(conditionsString); 62 | }); 63 | 64 | it('should accept labels and conditions', () => { 65 | const conditions = { key: 'value' }; 66 | const conditionsString = '{ key: $key }'; 67 | 68 | const multiLabelPattern = new ConcretePattern(['label1', 'label2'], conditions); 69 | expect(multiLabelPattern.getLabelsString()).to.equal(':label1:label2'); 70 | expect(multiLabelPattern.getNameString()).to.equal(''); 71 | expect(multiLabelPattern.getConditionsParamString()) 72 | .to.equal(conditionsString); 73 | }); 74 | 75 | it('should accept all three parameters', () => { 76 | const conditions = { key: 'value' }; 77 | const conditionsString = '{ key: $key }'; 78 | 79 | const singleLabelPattern = new ConcretePattern('name', 'label', conditions); 80 | expect(singleLabelPattern.getLabelsString()).to.equal(':label'); 81 | expect(singleLabelPattern.getNameString()).to.equal('name'); 82 | expect(singleLabelPattern.getConditionsParamString()) 83 | .to.equal(conditionsString); 84 | 85 | const multiLabelPattern = new ConcretePattern('name', ['label1', 'label2'], conditions); 86 | expect(multiLabelPattern.getLabelsString()).to.equal(':label1:label2'); 87 | expect(multiLabelPattern.getNameString()).to.equal('name'); 88 | expect(singleLabelPattern.getConditionsParamString()).to.equal(conditionsString); 89 | }); 90 | 91 | it('should not accept any other combinations of parameters', () => { 92 | expect(() => new ConcretePattern([], 'label')) 93 | .throws(TypeError, 'Name', 'when name is an array'); 94 | expect(() => new ConcretePattern({}, 'label')) 95 | .throws(TypeError, 'Name', 'when name is an object'); 96 | expect(() => new ConcretePattern('', {}, {})) 97 | .throws(TypeError, 'Labels', 'labels is an object'); 98 | expect(() => new ConcretePattern('', '', '' as any)) 99 | .throws(TypeError, 'Conditions', 'conditions is a string'); 100 | }); 101 | }); 102 | -------------------------------------------------------------------------------- /src/clauses/pattern.ts: -------------------------------------------------------------------------------- 1 | import { 2 | mapValues, map, isEmpty, Dictionary, isArray, isString, 3 | castArray, isObjectLike, isNil, Many, 4 | } from 'lodash'; 5 | import { Clause } from '../clause'; 6 | import { Parameter } from '../parameter-bag'; 7 | import { stringifyLabels } from '../utils'; 8 | 9 | export abstract class Pattern extends Clause { 10 | protected useExpandedConditions: boolean | undefined; 11 | protected conditionParams: Dictionary | Parameter = {}; 12 | protected name: string; 13 | protected labels: string[]; 14 | protected conditions: Dictionary; 15 | 16 | constructor( 17 | name?: Many | Dictionary, 18 | labels?: Many | Dictionary, 19 | conditions?: Dictionary, 20 | protected options = { expanded: true }, 21 | ) { 22 | super(); 23 | const isConditions = (a: any): a is Dictionary => isObjectLike(a) && !isArray(a); 24 | let tempName = name; 25 | let tempLabels = labels; 26 | let tempConditions = conditions; 27 | 28 | if (isNil(tempConditions)) { 29 | if (isConditions(tempLabels)) { 30 | tempConditions = tempLabels; 31 | tempLabels = undefined; 32 | } else if (isNil(tempLabels) && isConditions(tempName)) { 33 | tempConditions = tempName; 34 | tempName = undefined; 35 | } else { 36 | tempConditions = {}; 37 | } 38 | } 39 | 40 | if (isNil(tempLabels)) { 41 | if (isArray(tempName)) { 42 | tempLabels = tempName; 43 | tempName = undefined; 44 | } else { 45 | tempLabels = []; 46 | } 47 | } 48 | 49 | if (isNil(tempName)) { 50 | tempName = ''; 51 | } 52 | 53 | if (!isString(tempName)) { 54 | throw new TypeError('Name must be a string.'); 55 | } 56 | if (!isString(tempLabels) && !isArray(tempLabels)) { 57 | throw new TypeError('Labels must be a string or an array'); 58 | } 59 | if (!isConditions(tempConditions)) { 60 | throw new TypeError('Conditions must be an object.'); 61 | } 62 | 63 | this.labels = castArray(tempLabels); 64 | this.name = tempName; 65 | this.conditions = tempConditions; 66 | this.setExpandedConditions(options.expanded); 67 | } 68 | 69 | setExpandedConditions(expanded: boolean) { 70 | if (this.useExpandedConditions !== expanded) { 71 | this.useExpandedConditions = expanded; 72 | this.rebindConditionParams(); 73 | } 74 | } 75 | 76 | rebindConditionParams() { 77 | // Delete old bindings 78 | if (this.conditionParams instanceof Parameter) { 79 | this.parameterBag.deleteParam(this.conditionParams.name); 80 | } else { 81 | for (const key in this.conditionParams) { 82 | this.parameterBag.deleteParam(this.conditionParams[key].name); 83 | } 84 | } 85 | 86 | // Rebind params 87 | if (!isEmpty(this.conditions)) { 88 | if (this.useExpandedConditions) { 89 | this.conditionParams = mapValues(this.conditions, (value, name) => { 90 | return this.parameterBag.addParam(value, name); 91 | }); 92 | } else { 93 | this.conditionParams = this.parameterBag.addParam(this.conditions, 'conditions'); 94 | } 95 | } else { 96 | this.conditionParams = {}; 97 | } 98 | } 99 | 100 | getNameString() { 101 | return this.name ? this.name : ''; 102 | } 103 | 104 | getLabelsString(relation = false) { 105 | return stringifyLabels(this.labels, relation); 106 | } 107 | 108 | getConditionsParamString() { 109 | if (isEmpty(this.conditions)) { 110 | return ''; 111 | } 112 | 113 | if (this.useExpandedConditions) { 114 | const strings = map(this.conditionParams, (param, name) => { 115 | return `${name}: ${param}`; 116 | }); 117 | return `{ ${strings.join(', ')} }`; 118 | } 119 | return this.conditionParams.toString(); 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/clauses/raw.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { values } from 'lodash'; 3 | import { Raw } from './raw'; 4 | 5 | describe('Raw', () => { 6 | it('should return the same string it is given', () => { 7 | const query = new Raw('ADD INDEX node.id'); 8 | expect(query.build()).to.equal('ADD INDEX node.id'); 9 | }); 10 | 11 | it('should accept parameters', () => { 12 | const query = new Raw('SET n.id = $id', { id: 3 }); 13 | expect(query.build()).to.equal('SET n.id = $id'); 14 | expect(query.getParams()).to.have.property('id') 15 | .that.equals(3); 16 | }); 17 | 18 | it('should accept template tag results', () => { 19 | const tag = (strings: TemplateStringsArray, ...args: any[]) => ({ strings, args }); 20 | const { strings, args } = tag `SET n.id = ${3}`; 21 | const clause = new Raw(strings, ...args); 22 | expect(clause.build()).to.match(/^SET n.id = \$[a-zA-Z0-9]+$/); 23 | expect(values(clause.getParams())).to.have.members([3]); 24 | }); 25 | 26 | it('should throw an error when parameters is not the correct type', () => { 27 | const makeAny = (): any => 3; 28 | const makeClause = () => new Raw('SET n.id = $id', makeAny()); 29 | expect(makeClause).to.throw(TypeError, /params should be an object/i); 30 | }); 31 | 32 | it('should throw an error when clause is not the correct type', () => { 33 | const makeAny = (): any => new Date(); 34 | const makeClause = () => new Raw(makeAny()); 35 | expect(makeClause).to.throw(TypeError, /clause should be a string or an array/i); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /src/clauses/raw.ts: -------------------------------------------------------------------------------- 1 | import { 2 | isString, 3 | isArray, 4 | isObjectLike, 5 | map, 6 | flatten, 7 | zip, 8 | isNil, 9 | } from 'lodash'; 10 | import { Clause } from '../clause'; 11 | 12 | export class Raw extends Clause { 13 | clause: string; 14 | 15 | constructor(clause: string | TemplateStringsArray, ...args: any[]) { 16 | super(); 17 | 18 | if (isString(clause)) { 19 | this.clause = clause; 20 | const params = args[0]; 21 | if (isObjectLike(params)) { 22 | for (const key in params) { 23 | if (Object.hasOwnProperty.call(params, key)) { 24 | this.addParam(params[key], key); 25 | } 26 | } 27 | } else if (!isNil(params)) { 28 | throw new TypeError('When passing a string clause to Raw, params should be an object'); 29 | } 30 | } else if (isArray(clause)) { 31 | const queryParams = map(args, param => this.addParam(param)); 32 | this.clause = flatten(zip(clause, queryParams)).join(''); 33 | } else { 34 | throw new TypeError('Clause should be a string or an array'); 35 | } 36 | } 37 | 38 | build() { 39 | return this.clause; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/clauses/relation-pattern.spec.ts: -------------------------------------------------------------------------------- 1 | import { keys, values } from 'lodash'; 2 | import { RelationPattern } from './relation-pattern'; 3 | import { expect } from '../../test-setup'; 4 | 5 | describe('Relation', () => { 6 | describe('#build', () => { 7 | it('should build a relation pattern directed inwards', () => { 8 | const rel = new RelationPattern('in'); 9 | const queryObj = rel.buildQueryObject(); 10 | 11 | expect(queryObj.query).to.equal('<--'); 12 | expect(queryObj.params).to.be.empty; 13 | }); 14 | 15 | it('should build a relation pattern directed outwards', () => { 16 | const rel = new RelationPattern('out'); 17 | const queryObj = rel.buildQueryObject(); 18 | 19 | expect(queryObj.query).to.equal('-->'); 20 | expect(queryObj.params).to.be.empty; 21 | }); 22 | 23 | it('should build a relation pattern that is directionless', () => { 24 | const rel = new RelationPattern('either'); 25 | const queryObj = rel.buildQueryObject(); 26 | 27 | expect(queryObj.query).to.equal('--'); 28 | expect(queryObj.params).to.be.empty; 29 | }); 30 | 31 | it('should build a relation pattern with a variable name', () => { 32 | const rel = new RelationPattern('in', 'link'); 33 | const queryObj = rel.buildQueryObject(); 34 | 35 | expect(queryObj.query).to.equal('<-[link]-'); 36 | expect(queryObj.params).to.be.empty; 37 | }); 38 | 39 | it('should build a relation pattern with a label', () => { 40 | const rel = new RelationPattern('in', 'link', 'FriendsWith'); 41 | const queryObj = rel.buildQueryObject(); 42 | 43 | expect(queryObj.query).to.equal('<-[link:FriendsWith]-'); 44 | expect(queryObj.params).to.be.empty; 45 | }); 46 | 47 | it('should build a relation pattern with multiple labels', () => { 48 | const rel = new RelationPattern('in', ['FriendsWith', 'WorksWith']); 49 | const queryObj = rel.buildQueryObject(); 50 | 51 | expect(queryObj.query).to.equal('<-[:FriendsWith|WorksWith]-'); 52 | expect(queryObj.params).to.be.empty; 53 | }); 54 | 55 | it('should build a relation pattern with conditions', () => { 56 | const rel = new RelationPattern('out', { recent: true, years: 7 }); 57 | const queryObj = rel.buildQueryObject(); 58 | 59 | expect(queryObj.query).to.equal('-[{ recent: $recent, years: $years }]->'); 60 | expect(keys(queryObj.params)).to.have.length(2); 61 | expect(values(queryObj.params)).to.have.members([true, 7]); 62 | }); 63 | 64 | it('should build a relation pattern with path length', () => { 65 | [ 66 | [4, '*4'], 67 | [[2, 4], '*2..4'], 68 | ['*', '*'], 69 | [[2, null], '*2..'], 70 | ].forEach(([length, expected]) => { 71 | const rel = new RelationPattern('out', length); 72 | const queryObj = rel.buildQueryObject(); 73 | 74 | expect(queryObj.query).to.equal(`-[${expected}]->`); 75 | expect(queryObj.params).to.be.empty; 76 | }); 77 | }); 78 | 79 | it('should build a relation pattern with an empty label list', () => { 80 | const rel = new RelationPattern('in', []); 81 | const queryObj = rel.buildQueryObject(); 82 | 83 | expect(queryObj.query).to.equal('<--'); 84 | expect(queryObj.params).to.be.empty; 85 | }); 86 | 87 | it('should build a relation pattern with a name and conditions', () => { 88 | const rel = new RelationPattern('out', 'link', { recent: true, years: 7 }); 89 | const queryObj = rel.buildQueryObject(); 90 | 91 | expect(queryObj.query).to.equal('-[link { recent: $recent, years: $years }]->'); 92 | expect(keys(queryObj.params)).to.have.length(2); 93 | expect(values(queryObj.params)).to.have.members([true, 7]); 94 | }); 95 | 96 | it('should build a relation pattern with labels and conditions', () => { 97 | const rel = new RelationPattern('out', ['FriendsWith'], { recent: true, years: 7 }); 98 | const queryObj = rel.buildQueryObject(); 99 | 100 | expect(queryObj.query).to.equal('-[:FriendsWith { recent: $recent, years: $years }]->'); 101 | expect(keys(queryObj.params)).to.have.length(2); 102 | expect(values(queryObj.params)).to.have.members([true, 7]); 103 | }); 104 | 105 | it('should build a relation pattern with flexible length', () => { 106 | const any = new RelationPattern('out', '', [], {}, '*'); 107 | const exact = new RelationPattern('out', '', [], {}, 3); 108 | const minBound = new RelationPattern('out', '', [], {}, [2]); 109 | const bothBounds = new RelationPattern('out', '', [], {}, [2, 7]); 110 | 111 | expect(any.buildQueryObject().query).to.equal('-[*]->'); 112 | expect(exact.buildQueryObject().query).to.equal('-[*3]->'); 113 | expect(minBound.buildQueryObject().query).to.equal('-[*2..]->'); 114 | expect(bothBounds.buildQueryObject().query).to.equal('-[*2..7]->'); 115 | }); 116 | 117 | it('should build a complete relation pattern', () => { 118 | const labels = ['FriendsWith', 'WorksWith']; 119 | const conditions = { recent: true, years: 7 }; 120 | const rel = new RelationPattern('either', 'f', labels, conditions, [2, 3]); 121 | const queryObj = rel.buildQueryObject(); 122 | 123 | const relation = '-[f:FriendsWith|WorksWith*2..3 { recent: $recent, years: $years }]-'; 124 | expect(queryObj.query).to.equal(relation); 125 | expect(keys(queryObj.params)).to.have.length(2); 126 | expect(values(queryObj.params)).to.have.members([true, 7]); 127 | }); 128 | }); 129 | }); 130 | -------------------------------------------------------------------------------- /src/clauses/relation-pattern.ts: -------------------------------------------------------------------------------- 1 | import { Dictionary, trim, Many, isNil, isNumber, isArray, every } from 'lodash'; 2 | import { Pattern } from './pattern'; 3 | import { PathLength, stringifyPathLength } from '../utils'; 4 | 5 | const isPathLengthArray = (value: any) => ( 6 | isArray(value) && every(value, item => isNumber(item) || isNil(item)) && value.length > 0 7 | ); 8 | const isPathLength = (value: any): value is PathLength => ( 9 | value === '*' || isNumber(value) || isPathLengthArray(value) 10 | ); 11 | 12 | export type RelationDirection = 'in' | 'out' | 'either'; 13 | 14 | export class RelationPattern extends Pattern { 15 | dir: RelationDirection; 16 | length: PathLength | undefined; 17 | 18 | constructor( 19 | dir: RelationDirection, 20 | name?: Many | Dictionary | PathLength, 21 | labels?: Many | Dictionary | PathLength, 22 | conditions?: Dictionary | PathLength, 23 | length?: PathLength, 24 | ) { 25 | let tempName = name; 26 | let tempLabels = labels; 27 | let tempConditions = conditions; 28 | let tempLength = length; 29 | 30 | if (isNil(tempLength)) { 31 | if (isPathLength(tempConditions)) { 32 | tempLength = tempConditions; 33 | tempConditions = undefined; 34 | } else if (isNil(tempConditions) && isPathLength(tempLabels)) { 35 | tempLength = tempLabels; 36 | tempLabels = undefined; 37 | } else if (isNil(tempConditions) && isNil(tempLabels) && isPathLength(tempName)) { 38 | tempLength = tempName; 39 | tempName = undefined; 40 | } 41 | } 42 | 43 | if (isPathLength(tempName) || isPathLength(tempLabels) || isPathLength(tempConditions)) { 44 | throw new TypeError('Invalid argument combination.'); 45 | } 46 | 47 | super(tempName, tempLabels, tempConditions); 48 | this.dir = dir; 49 | this.length = tempLength; 50 | } 51 | 52 | build() { 53 | const name = this.getNameString(); 54 | const labels = this.getLabelsString(true); 55 | const length = stringifyPathLength(this.length); 56 | const conditions = this.getConditionsParamString(); 57 | const query = trim(`${name}${labels}${length} ${conditions}`); 58 | 59 | const arrows: Record<'in' | 'out' | 'either', string[]> = { 60 | in: ['<-', '-'], 61 | out: ['-', '->'], 62 | either: ['-', '-'], 63 | }; 64 | return arrows[this.dir].join(query.length > 0 ? `[${query}]` : ''); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/clauses/remove.spec.ts: -------------------------------------------------------------------------------- 1 | import { Remove } from './remove'; 2 | import { expect } from '../../test-setup'; 3 | 4 | describe('Remove', () => { 5 | describe('#build', () => { 6 | it('should accept a single label', () => { 7 | const clause = new Remove({ labels: { node: 'Active' } }); 8 | expect(clause.build()).to.equal('REMOVE node:Active'); 9 | }); 10 | 11 | it('should accept multiple labels', () => { 12 | const clause = new Remove({ labels: { node: ['Active', 'Accepted'] } }); 13 | expect(clause.build()).to.equal('REMOVE node:Active:Accepted'); 14 | }); 15 | 16 | it('should accept labels for multiple nodes', () => { 17 | const clause = new Remove({ labels: { node: ['Active', 'Accepted'], node2: 'InTransit' } }); 18 | expect(clause.build()).to.equal('REMOVE node:Active:Accepted, node2:InTransit'); 19 | }); 20 | 21 | it('should accept a single property', () => { 22 | const clause = new Remove({ properties: { node: 'age' } }); 23 | expect(clause.build()).to.equal('REMOVE node.age'); 24 | }); 25 | 26 | it('should accept multiple properties', () => { 27 | const clause = new Remove({ properties: { node: ['age', 'password'] } }); 28 | expect(clause.build()).to.equal('REMOVE node.age, node.password'); 29 | }); 30 | 31 | it('should accept properties for multiple nodes', () => { 32 | const clause = new Remove({ properties: { node: ['age', 'password'], node2: 'contacted' } }); 33 | expect(clause.build()).to.equal('REMOVE node.age, node.password, node2.contacted'); 34 | }); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /src/clauses/remove.ts: -------------------------------------------------------------------------------- 1 | import { Clause } from '../clause'; 2 | import { Dictionary, Many, map, mapValues, flatMap, castArray } from 'lodash'; 3 | import { stringifyLabels } from '../utils'; 4 | 5 | export type RemoveProperties = { 6 | labels?: Dictionary>; 7 | properties?: Dictionary>; 8 | }; 9 | 10 | export class Remove extends Clause { 11 | protected labels: Dictionary; 12 | protected properties: Dictionary; 13 | 14 | constructor({ labels = {}, properties = {} }: RemoveProperties) { 15 | super(); 16 | this.labels = mapValues(labels, castArray); 17 | this.properties = mapValues(properties, castArray); 18 | } 19 | 20 | build() { 21 | const labels = map(this.labels, (labels, key) => key + stringifyLabels(labels)); 22 | const properties = flatMap(this.properties, (properties, key) => ( 23 | map(properties, property => `${key}.${property}`) 24 | )); 25 | return `REMOVE ${[...labels, ...properties].join(', ')}`; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/clauses/return.spec.ts: -------------------------------------------------------------------------------- 1 | import { Return } from './return'; 2 | import { expect } from 'chai'; 3 | 4 | describe('Return', () => { 5 | describe('#build', () => { 6 | it('should start with RETURN', () => { 7 | const query = new Return('node'); 8 | expect(query.build()).to.equal('RETURN node'); 9 | }); 10 | 11 | it('should start with RETURN DISTINCT', () => { 12 | const query = new Return('node', { distinct: true }); 13 | expect(query.build()).to.equal('RETURN DISTINCT node'); 14 | }); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/clauses/return.ts: -------------------------------------------------------------------------------- 1 | import { Many } from 'lodash'; 2 | import { Term, TermListClause } from './term-list-clause'; 3 | 4 | export interface ReturnOptions { 5 | distinct?: boolean; 6 | } 7 | 8 | export class Return extends TermListClause { 9 | constructor(terms: Many, protected options: ReturnOptions = {}) { 10 | super(terms); 11 | } 12 | 13 | build() { 14 | const distinct = this.options.distinct ? ' DISTINCT' : ''; 15 | return `RETURN${distinct} ${super.build()}`; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/clauses/set.spec.ts: -------------------------------------------------------------------------------- 1 | import { Set } from './set'; 2 | import { expect } from 'chai'; 3 | 4 | describe('Set', () => { 5 | it('should add a label to a node', () => { 6 | const query = new Set({ labels: { node: 'Label' } }); 7 | expect(query.build()).to.equal('SET node:Label'); 8 | }); 9 | 10 | it('should add multiple labels to a node', () => { 11 | const query = new Set({ labels: { node: ['Label1', 'Label2'] } }); 12 | expect(query.build()).to.equal('SET node:Label1:Label2'); 13 | }); 14 | 15 | it('should be set labels on multiple nodes', () => { 16 | const query = new Set({ labels: { node1: 'Label', node2: ['Label1', 'Label2'] } }); 17 | expect(query.build()).to.equal('SET node1:Label, node2:Label1:Label2'); 18 | }); 19 | 20 | it('should set a property to a variable', () => { 21 | const query = new Set({ variables: { 'node.prop': 'variable' } }); 22 | expect(query.build()).to.equal('SET node.prop = variable'); 23 | }); 24 | 25 | it('should set multiple properties on a single node', () => { 26 | const query = new Set({ variables: { node: { prop1: 'variable1', prop2: 'variable2' } } }); 27 | expect(query.build()).to.equal('SET node.prop1 = variable1, node.prop2 = variable2'); 28 | }); 29 | 30 | it('should set multiple properties on multiple nodes', () => { 31 | const variables = { node1: { prop1: 'variable1' }, node2: { prop2: 'variable2' } }; 32 | const query = new Set({ variables }); 33 | expect(query.build()).to.equal('SET node1.prop1 = variable1, node2.prop2 = variable2'); 34 | }); 35 | 36 | it('should set a property to a value', () => { 37 | const query = new Set({ values: { node: 'value' } }); 38 | expect(query.build()).to.equal('SET node = $node'); 39 | expect(query.getParams()).to.have.property('node', 'value'); 40 | }); 41 | 42 | it('should set multiple properties on a single node', () => { 43 | const param = { prop1: 'value', prop2: 'value' }; 44 | const query = new Set({ values: { node: param } }); 45 | expect(query.build()).to.equal('SET node = $node'); 46 | expect(query.getParams()).to.have.property('node', param); 47 | }); 48 | 49 | it('should set properties of multiple nodes', () => { 50 | const param1 = { prop1: 'value', prop2: 'value' }; 51 | const param2 = { prop1: 'value', prop2: 'value' }; 52 | const query = new Set({ values: { node: param1, node2: param2 } }); 53 | const queryParams = query.getParams(); 54 | 55 | expect(query.build()).to.equal('SET node = $node, node2 = $node2'); 56 | expect(queryParams).to.have.property('node', param1); 57 | expect(queryParams).to.have.property('node2', param2); 58 | }); 59 | 60 | it('should merge properties when merge is true', () => { 61 | const data = { 62 | values: { node: { name: 'complex value' } }, 63 | variables: { node2: 'variable' }, 64 | }; 65 | const query = new Set(data, { merge: true }); 66 | expect(query.build()).to.equal('SET node += $node, node2 += variable'); 67 | }); 68 | 69 | it('should not merge plain values even when merge is true', () => { 70 | const data = { 71 | values: { 'node.property': 'value', otherNode: { dictionary: 'value' } }, 72 | }; 73 | const query = new Set(data, { merge: true }); 74 | expect(query.build()).to.equal('SET node.property = $nodeProperty, otherNode += $otherNode'); 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /src/clauses/set.ts: -------------------------------------------------------------------------------- 1 | import { 2 | concat, map, mapValues, castArray, Dictionary, 3 | Many, isObject, isString, 4 | } from 'lodash'; 5 | import { Clause } from '../clause'; 6 | import { stringifyLabels } from '../utils'; 7 | import { Parameter } from '../parameter-bag'; 8 | 9 | export type SetProperties = { 10 | labels?: Dictionary>, 11 | values?: Dictionary, 12 | variables?: Dictionary>, 13 | }; 14 | 15 | export interface SetOptions { 16 | merge?: boolean; 17 | } 18 | 19 | export class Set extends Clause { 20 | protected labels: Dictionary; 21 | protected values: Dictionary; 22 | protected variables: Dictionary>; 23 | protected merge: boolean; 24 | 25 | protected makeLabelStatement = (labels: Many, key: string) => { 26 | return key + stringifyLabels(labels); 27 | } 28 | 29 | protected makeValueStatement = (value: any, key: string): string => { 30 | const valueIsObject = value instanceof Parameter ? isObject(value.value) : isObject(value); 31 | const op = this.merge && valueIsObject ? ' += ' : ' = '; 32 | return key + op + value; 33 | } 34 | 35 | protected makeVariableStatement = (value: string | Dictionary, key: string): string => { 36 | const op = this.merge ? ' += ' : ' = '; 37 | if (isString(value)) { 38 | return key + op + value; 39 | } 40 | const operationStrings = map(value, (value, prop) => `${key}.${prop}${op}${value}`); 41 | return operationStrings.join(', '); 42 | } 43 | 44 | constructor( 45 | { labels, values, variables }: SetProperties, 46 | options: SetOptions = {}, 47 | ) { 48 | super(); 49 | 50 | this.labels = mapValues(labels, castArray); 51 | this.values = mapValues(values, (value, name) => { 52 | return this.parameterBag.addParam(value, name); 53 | }); 54 | this.variables = variables || {}; 55 | this.merge = !!options.merge; 56 | } 57 | 58 | build() { 59 | const labels = map(this.labels, this.makeLabelStatement); 60 | const values = map(this.values, this.makeValueStatement); 61 | const variables = map(this.variables, this.makeVariableStatement); 62 | return `SET ${concat(labels, values, variables).join(', ')}`; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/clauses/skip.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import neo4jDriver from 'neo4j-driver'; 3 | import { Skip } from './skip'; 4 | 5 | describe('Skip', () => { 6 | describe('#build', () => { 7 | it('should add a produce a skip clause', () => { 8 | const query = new Skip(10); 9 | expect(query.build()).to.equal('SKIP $skipCount'); 10 | expect(query.getParams()).to.eql({ skipCount: neo4jDriver.int(10) }); 11 | }); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /src/clauses/skip.ts: -------------------------------------------------------------------------------- 1 | import neo4jDriver from 'neo4j-driver'; 2 | import { Clause } from '../clause'; 3 | import { Parameter } from '../parameter-bag'; 4 | 5 | export class Skip extends Clause { 6 | protected amountParam: Parameter; 7 | 8 | constructor(public amount: number) { 9 | super(); 10 | this.amountParam = this.addParam(neo4jDriver.int(amount), 'skipCount'); 11 | } 12 | 13 | build() { 14 | return `SKIP ${this.amountParam}`; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/clauses/term-list-clause.spec.ts: -------------------------------------------------------------------------------- 1 | import { TermListClause } from './term-list-clause'; 2 | import { expect } from 'chai'; 3 | 4 | describe('TermListClause', () => { 5 | describe('#build', () => { 6 | it('should return a single property', () => { 7 | const termList = new TermListClause('node'); 8 | expect(termList.build()).to.equal('node'); 9 | expect(termList.getParams()).to.be.empty; 10 | }); 11 | 12 | it('should return a single renamed property', () => { 13 | const termList = new TermListClause({ node: 'outerNode' }); 14 | expect(termList.build()).to.equal('node AS outerNode'); 15 | expect(termList.getParams()).to.be.empty; 16 | }); 17 | 18 | it('should prefix an array of properties with an object key', () => { 19 | const termList = new TermListClause({ node: ['count', 'score', 'timestamp'] }); 20 | expect(termList.build()).to.equal('node.count, node.score, node.timestamp'); 21 | expect(termList.getParams()).to.be.empty; 22 | }); 23 | 24 | it('should rename a nested object property', () => { 25 | const termList = new TermListClause({ node: ['count', { timestamp: 'created_at' }] }); 26 | expect(termList.build()).to.equal('node.count, node.timestamp AS created_at'); 27 | expect(termList.getParams()).to.be.empty; 28 | }); 29 | 30 | it('should rename a single nested object property', () => { 31 | const termList = new TermListClause({ node: { name: 'otherName', timestamp: 'created_at' } }); 32 | expect(termList.build()).to.equal('node.name AS otherName, node.timestamp AS created_at'); 33 | expect(termList.getParams()).to.be.empty; 34 | }); 35 | 36 | it('should be able to apply functions to properties', () => { 37 | const termList = new TermListClause('avg(node.count)'); 38 | expect(termList.build()).to.equal('avg(node.count)'); 39 | expect(termList.getParams()).to.be.empty; 40 | }); 41 | 42 | it('should join a list of properties', () => { 43 | const termList = new TermListClause(['startNode', 'rel', 'endNode']); 44 | expect(termList.build()).to.equal('startNode, rel, endNode'); 45 | expect(termList.getParams()).to.be.empty; 46 | }); 47 | 48 | it('should produce a complete term list', () => { 49 | const termList = new TermListClause([ 50 | 'startNode', 51 | 'count(rel)', 52 | { 53 | endNode: [ 54 | 'score', 55 | { timestamp: 'created_at' }, 56 | ], 57 | }, 58 | ]); 59 | expect(termList.build()).to 60 | .equal('startNode, count(rel), endNode.score, endNode.timestamp AS created_at'); 61 | expect(termList.getParams()).to.be.empty; 62 | }); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /src/clauses/term-list-clause.ts: -------------------------------------------------------------------------------- 1 | import { 2 | flatMapDeep, 3 | map, 4 | isPlainObject, 5 | isString, 6 | isArray, 7 | castArray, 8 | reduce, 9 | Dictionary, 10 | Many, 11 | } from 'lodash'; 12 | import { Clause } from '../clause'; 13 | 14 | export type Properties = Many>; 15 | export type Term 16 | = string 17 | | Dictionary; 18 | 19 | export class TermListClause extends Clause { 20 | protected terms: Term[]; 21 | 22 | /** 23 | * Accepts: 24 | * node -> string 25 | * many nodes -> string[] 26 | * nodes with aliases -> Dictionary 27 | * node properties -> Dictionary 28 | * node properties with aliases -> Dictionary[]> 29 | * or an array of any combination 30 | */ 31 | constructor(terms: Many) { 32 | super(); 33 | this.terms = castArray(terms); 34 | } 35 | 36 | build() { 37 | return this.toString(); 38 | } 39 | 40 | toString() { 41 | return flatMapDeep(this.terms, term => this.stringifyTerm(term)).join(', '); 42 | } 43 | 44 | private stringifyTerm(term: Term): string[] { 45 | // Just a node 46 | if (isString(term)) { 47 | return [this.stringifyProperty(term)]; 48 | } 49 | 50 | // Node properties or aliases 51 | if (isPlainObject(term)) { 52 | return this.stringifyDictionary(term); 53 | } 54 | 55 | return []; 56 | } 57 | 58 | private stringifyProperty(prop: string, alias?: string, node?: string): string { 59 | const prefixString = node ? `${node}.` : ''; 60 | const aliasString = alias ? `${alias} AS ` : ''; 61 | return prefixString + aliasString + prop; 62 | } 63 | 64 | private stringifyProperties(props: Properties, alias?: string, node?: string): string[] { 65 | const convertToString = (list: string[], prop: string | Dictionary) => { 66 | if (isString(prop)) { 67 | // Single node property 68 | list.push(this.stringifyProperty(prop, alias, node)); 69 | } else { 70 | // Node properties with aliases 71 | list.push(...map(prop, (name, alias) => this.stringifyProperty(name, alias, node))); 72 | } 73 | return list; 74 | }; 75 | return reduce(castArray(props), convertToString, []); 76 | } 77 | 78 | private stringifyDictionary(node: Dictionary): string[] { 79 | return reduce( 80 | node, 81 | (list, prop, key) => { 82 | if (isString(prop)) { 83 | // Alias 84 | list.push(this.stringifyProperty(prop, key)); 85 | } else { 86 | // Node with properties 87 | list.push(...this.stringifyProperties(prop, undefined, key)); 88 | } 89 | return list; 90 | }, 91 | [] as string[], 92 | ); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/clauses/union.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from '../../test-setup'; 2 | import { Union } from './union'; 3 | 4 | describe('Union', () => { 5 | it('should exactly equal UNION', () => { 6 | const clause = new Union(); 7 | expect(clause.build()).to.equal('UNION'); 8 | }); 9 | 10 | it('should exactly equal UNION ALL when all is true', () => { 11 | const clause = new Union(true); 12 | expect(clause.build()).to.equal('UNION ALL'); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /src/clauses/union.ts: -------------------------------------------------------------------------------- 1 | import { Clause } from '../clause'; 2 | 3 | export class Union extends Clause { 4 | constructor(public all: boolean = false) { 5 | super(); 6 | } 7 | 8 | build() { 9 | return `UNION${this.all ? ' ALL' : ''}`; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/clauses/unwind.spec.ts: -------------------------------------------------------------------------------- 1 | import { Unwind } from './unwind'; 2 | import { expect } from 'chai'; 3 | 4 | describe('Unwind', () => { 5 | it('should start with Unwind', () => { 6 | const clause = new Unwind([], 'node'); 7 | expect(clause.build()).to.equal('UNWIND $list AS node'); 8 | }); 9 | 10 | it('should create a param for the list', () => { 11 | const list = [1, 2, 3]; 12 | const clause = new Unwind(list, 'node'); 13 | expect(clause.getParams()).to.deep.equal({ list }); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /src/clauses/unwind.ts: -------------------------------------------------------------------------------- 1 | import { Clause } from '../clause'; 2 | import { Parameter } from '../parameter-bag'; 3 | 4 | export class Unwind extends Clause { 5 | protected listParam: Parameter; 6 | 7 | constructor( 8 | protected list: any[], 9 | protected name: string, 10 | ) { 11 | super(); 12 | this.listParam = this.parameterBag.addParam(this.list, 'list'); 13 | } 14 | 15 | build() { 16 | return `UNWIND ${this.listParam} AS ${this.name}`; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/clauses/where-comparators.spec.ts: -------------------------------------------------------------------------------- 1 | import { Dictionary } from 'lodash'; 2 | import { expect } from 'chai'; 3 | import { 4 | between, Comparator, 5 | contains, endsWith, equals, exists, greaterEqualTo, greaterThan, hasLabel, 6 | inArray, 7 | isNull, lessEqualTo, lessThan, regexp, startsWith, 8 | } from './where-comparators'; 9 | import { ParameterBag } from '../parameter-bag'; 10 | 11 | describe('Where Comparators', () => { 12 | let bag: ParameterBag; 13 | 14 | beforeEach(() => { 15 | bag = new ParameterBag(); 16 | }); 17 | 18 | const simpleOperators: Dictionary<(value: any, variable?: boolean) => Comparator> = { 19 | equals, 20 | greaterThan, 21 | greaterEqualTo, 22 | lessThan, 23 | lessEqualTo, 24 | startsWith, 25 | endsWith, 26 | contains, 27 | inArray, 28 | }; 29 | 30 | const opSymbols: Dictionary = { 31 | equals: '=', 32 | greaterThan: '>', 33 | greaterEqualTo: '>=', 34 | lessThan: '<', 35 | lessEqualTo: '<=', 36 | startsWith: 'STARTS WITH', 37 | endsWith: 'ENDS WITH', 38 | contains: 'CONTAINS', 39 | inArray: 'IN', 40 | }; 41 | 42 | for (const name in simpleOperators) { 43 | describe(name, () => { 44 | it('should perform a comparision', () => { 45 | const clause = simpleOperators[name]('value')(bag, 'name'); 46 | expect(clause).to.equal(`name ${opSymbols[name]} $name`); 47 | expect(bag.getParams()).to.have.property('name') 48 | .that.equals('value'); 49 | }); 50 | 51 | it('should support using a cypher variable', () => { 52 | const clause = simpleOperators[name]('variable', true)(bag, 'name'); 53 | expect(clause).to.equal(`name ${opSymbols[name]} variable`); 54 | expect(bag.getParams()).to.be.empty; 55 | }); 56 | }); 57 | } 58 | 59 | describe('regexp', () => { 60 | it('should perform a case sensitive comparision', () => { 61 | const clause = regexp('value')(bag, 'name'); 62 | expect(clause).to.equal('name =~ $name'); 63 | expect(bag.getParams()).to.have.property('name') 64 | .that.equals('value'); 65 | }); 66 | 67 | it('should perform a case insensitive comparision', () => { 68 | const clause = regexp('value', true)(bag, 'name'); 69 | expect(clause).to.equal('name =~ $name'); 70 | expect(bag.getParams()).to.have.property('name') 71 | .that.equals('(?i)value'); 72 | }); 73 | 74 | it('should support using a cypher variable', () => { 75 | const clause = regexp('variable', false, true)(bag, 'name'); 76 | expect(clause).to.equal('name =~ variable'); 77 | expect(bag.getParams()).to.be.empty; 78 | }); 79 | }); 80 | 81 | describe('exists', () => { 82 | it('should perform a comparision', () => { 83 | const clause = exists()(bag, 'name'); 84 | expect(clause).to.equal('exists(name)'); 85 | }); 86 | }); 87 | 88 | describe('isNull', () => { 89 | it('should perform a comparision', () => { 90 | const clause = isNull()(bag, 'name'); 91 | expect(clause).to.equal('name IS NULL'); 92 | }); 93 | }); 94 | 95 | describe('hasLabel', () => { 96 | it('should perform a comparison', () => { 97 | const clause = hasLabel('LABEL')(bag, 'name'); 98 | expect(clause).to.equal('name:LABEL'); 99 | }); 100 | }); 101 | 102 | describe('between', () => { 103 | it('should perform multiple comparisons', () => { 104 | const clauses = between(1, 2)(bag, 'name'); 105 | expect(clauses).to.equal('name >= $lowerName AND name <= $upperName'); 106 | }); 107 | 108 | it('should support exclusive comparisons', () => { 109 | const clauses = between(1, 2, false)(bag, 'name'); 110 | expect(clauses).to.equal('name > $lowerName AND name < $upperName'); 111 | }); 112 | 113 | it('should support mixed comparisons', () => { 114 | let clauses = between(1, 2, true, false)(bag, 'name'); 115 | expect(clauses).to.equal('name >= $lowerName AND name < $upperName'); 116 | 117 | bag = new ParameterBag(); 118 | clauses = between(1, 2, false, true)(bag, 'name'); 119 | expect(clauses).to.equal('name > $lowerName AND name <= $upperName'); 120 | }); 121 | 122 | it('should support cypher variables', () => { 123 | const clauses = between('v1', 'v2', false, false, true)(bag, 'name'); 124 | expect(clauses).to.equal('name > v1 AND name < v2'); 125 | }); 126 | }); 127 | }); 128 | -------------------------------------------------------------------------------- /src/clauses/where-comparators.ts: -------------------------------------------------------------------------------- 1 | import { last, capitalize } from 'lodash'; 2 | import { ParameterBag } from '../parameter-bag'; 3 | 4 | export const comparisions = { 5 | equals, 6 | greaterThan, 7 | greaterEqualTo, 8 | lessThan, 9 | lessEqualTo, 10 | startsWith, 11 | endsWith, 12 | contains, 13 | inArray, 14 | hasLabel, 15 | exists, 16 | between, 17 | isNull, 18 | regexp, 19 | }; 20 | 21 | export type Comparator = (params: ParameterBag, name: string) => string; 22 | 23 | function compare(operator: string, value: any, variable?: boolean, paramName?: string): Comparator { 24 | return (params: ParameterBag, name: string): string => { 25 | const baseParamName = paramName || last(name.split('.')); 26 | const parts = [ 27 | name, 28 | operator, 29 | variable ? value : params.addParam(value, baseParamName), 30 | ]; 31 | return parts.join(' '); 32 | }; 33 | } 34 | 35 | /** 36 | * Equals comparator for use in where clauses. This is the default so you will 37 | * probably never need to use this. 38 | * 39 | * If you want to compare against a Neo4j variable you can set `variable` to 40 | * true and the value will be inserted literally into the query. 41 | * 42 | * ``` 43 | * query.where({ age: equals(18) }) 44 | * // WHERE age = 18 45 | * 46 | * query.where({ name: equals('clientName', true) }) 47 | * // WHERE age = clientName 48 | * ``` 49 | * @param value 50 | * @param {boolean} variable 51 | * @returns {Comparator} 52 | */ 53 | export function equals(value: any, variable?: boolean) { 54 | return compare('=', value, variable); 55 | } 56 | 57 | /** 58 | * Greater than comparator for use in where clauses. 59 | * 60 | * If you want to compare against a Neo4j variable you can set `variable` to 61 | * true and the value will be inserted literally into the query. 62 | * 63 | * ``` 64 | * query.where({ age: greaterThan(18) }) 65 | * // WHERE age > 18 66 | * 67 | * query.where({ age: greaterThan('clientAge', true) }) 68 | * // WHERE age > clientAge 69 | * ``` 70 | * @param value 71 | * @param {boolean} variable 72 | * @returns {Comparator} 73 | */ 74 | export function greaterThan(value: any, variable?: boolean) { 75 | return compare('>', value, variable); 76 | } 77 | 78 | /** 79 | * Greater or equal to comparator for use in where clauses. 80 | * 81 | * If you want to compare against a Neo4j variable you can set `variable` to 82 | * true and the value will be inserted literally into the query. 83 | * 84 | * ``` 85 | * query.where({ age: greaterEqualTo(18) }) 86 | * // WHERE age >= 18 87 | * 88 | * query.where({ age: greaterEqualTo('clientAge', true) }) 89 | * // WHERE age >= clientAge 90 | * ``` 91 | * @param value 92 | * @param {boolean} variable 93 | * @returns {Comparator} 94 | */ 95 | export function greaterEqualTo(value: any, variable?: boolean) { 96 | return compare('>=', value, variable); 97 | } 98 | 99 | /** 100 | * Less than comparator for use in where clauses. 101 | * 102 | * If you want to compare against a Neo4j variable you can set `variable` to 103 | * true and the value will be inserted literally into the query. 104 | * 105 | * ``` 106 | * query.where({ age: lessThan(18) }) 107 | * // WHERE age < 18 108 | * 109 | * query.where({ age: lessThan('clientAge', true) }) 110 | * // WHERE age < clientAge 111 | * ``` 112 | * @param value 113 | * @param {boolean} variable 114 | * @returns {Comparator} 115 | */ 116 | export function lessThan(value: any, variable?: boolean) { 117 | return compare('<', value, variable); 118 | } 119 | 120 | /** 121 | * Less or equal to comparator for use in where clauses. 122 | * 123 | * If you want to compare against a Neo4j variable you can set `variable` to 124 | * true and the value will be inserted literally into the query. 125 | * 126 | * ``` 127 | * query.where({ age: lessEqualTo(18) }) 128 | * // WHERE age <= 18 129 | * 130 | * query.where({ age: lessEqualTo('clientAge', true) }) 131 | * // WHERE age >= clientAge 132 | * ``` 133 | * @param value 134 | * @param {boolean} variable 135 | * @returns {Comparator} 136 | */ 137 | export function lessEqualTo(value: any, variable?: boolean) { 138 | return compare('<=', value, variable); 139 | } 140 | 141 | /** 142 | * Starts with comparator for use in where clauses. 143 | * 144 | * If you want to compare against a Neo4j variable you can set `variable` to 145 | * true and the value will be inserted literally into the query. 146 | * 147 | * ``` 148 | * query.where({ name: startsWith('steve') }) 149 | * // WHERE name STARTS WITH 'steve' 150 | * 151 | * query.where({ name: startsWith('clientName', true) }) 152 | * // WHERE name STARTS WITH clientName 153 | * ``` 154 | * @param value 155 | * @param {boolean} variable 156 | * @returns {Comparator} 157 | */ 158 | export function startsWith(value: string, variable?: boolean) { 159 | return compare('STARTS WITH', value, variable); 160 | } 161 | 162 | /** 163 | * Ends with comparator for use in where clauses. 164 | * 165 | * If you want to compare against a Neo4j variable you can set `variable` to 166 | * true and the value will be inserted literally into the query. 167 | * 168 | * ``` 169 | * query.where({ name: endsWith('steve') }) 170 | * // WHERE name ENDS WITH 'steve' 171 | * 172 | * query.where({ name: endsWith('clientName', true) }) 173 | * // WHERE name ENDS WITH clientName 174 | * ``` 175 | * @param value 176 | * @param {boolean} variable 177 | * @returns {Comparator} 178 | */ 179 | export function endsWith(value: string, variable?: boolean) { 180 | return compare('ENDS WITH', value, variable); 181 | } 182 | 183 | /** 184 | * Contains comparator for use in where clauses. 185 | * 186 | * If you want to compare against a Neo4j variable you can set `variable` to 187 | * true and the value will be inserted literally into the query. 188 | * 189 | * ``` 190 | * query.where({ name: contains('steve') }) 191 | * // WHERE name CONTAINS 'steve' 192 | * 193 | * query.where({ name: contains('clientName', true) }) 194 | * // WHERE name CONTAINS clientName 195 | * ``` 196 | * @param value 197 | * @param {boolean} variable 198 | * @returns {Comparator} 199 | */ 200 | export function contains(value: string, variable?: boolean) { 201 | return compare('CONTAINS', value, variable); 202 | } 203 | 204 | /** 205 | * In comparator for use in where clauses. 206 | * 207 | * If you want to compare against a Neo4j variable you can set `variable` to 208 | * true and the value will be inserted literally into the query. 209 | * 210 | * ``` 211 | * query.where({ name: inArray([ 'steve', 'william' ]) }) 212 | * // WHERE name IN [ 'steve', 'william' ] 213 | * 214 | * query.where({ name: inArray('clientNames', true) }) 215 | * // WHERE name IN clientNames 216 | * ``` 217 | * @param value 218 | * @param {boolean} variable 219 | * @returns {Comparator} 220 | */ 221 | export function inArray(value: any[], variable?: boolean) { 222 | return compare('IN', value, variable); 223 | } 224 | 225 | /** 226 | * Regexp comparator for use in where clauses. Also accepts a case insensitive 227 | * to make it easier to add the `'(?i)'` flag to the start of your regexp. 228 | * If you are already using flags in your regexp, you should not set insensitive 229 | * to true because it will prepend `'(?i)'` which will make your regexp 230 | * malformed. 231 | * 232 | * For convenience you can also pass a Javascript RegExp object into this 233 | * comparator, which will then be converted into a string before it is 234 | * passed to cypher. *However*, beware that the cypher regexp syntax is 235 | * inherited from [java]{@link 236 | * https://docs.oracle.com/javase/7/docs/api/java/util/regex/Pattern.html}, 237 | * and may have slight differences to the Javascript syntax. For example, 238 | * Javascript RegExp flags will not be preserved when sent to cypher. 239 | * 240 | * If you want to compare against a Neo4j variable you can set `variable` to 241 | * true and the value will be inserted literally into the query. 242 | * 243 | * ``` 244 | * query.where({ name: regexp('s.*e') }) 245 | * // WHERE name =~ 's.*e' 246 | * 247 | * query.where({ name: regexp('s.*e', true) }) 248 | * // WHERE name =~ '(?i)s.*e' 249 | * 250 | * query.where({ name: regexp('clientPattern', false, true) }) 251 | * // WHERE name =~ clientPattern 252 | * ``` 253 | * @param exp 254 | * @param insensitive 255 | * @param {boolean} variable 256 | * @returns {Comparator} 257 | */ 258 | export function regexp(exp: string | RegExp, insensitive?: boolean, variable?: boolean) { 259 | let stringExp = exp; 260 | if (exp instanceof RegExp) { 261 | // Convert regular expression to string and strip slashes and trailing flags. 262 | // This regular expression will always match something so we can use the ! operator to ignore 263 | // type errors. 264 | stringExp = exp.toString().match(/\/(.*)\/[a-z]*/)![1]; 265 | } 266 | return compare('=~', insensitive ? `(?i)${stringExp}` : stringExp, variable); 267 | } 268 | 269 | /** 270 | * Between comparator for use in where clauses. This comparator uses Neo4j's 271 | * shortcut comparison syntax: `18 <= age <= 65`. 272 | * 273 | * The `lower` and `upper` are the bounds of the comparison. You can use 274 | * `lowerInclusive` and `upperInclusive` to control whether it uses `<=` or `<` 275 | * for the comparison. They both default to `true`. 276 | * 277 | * If you pass only `lowerInclusive` then it will use that value for both. 278 | * 279 | * If you want to compare against a Neo4j variable you can set `variable` to 280 | * true and the value will be inserted literally into the query. 281 | * 282 | * ``` 283 | * query.where({ age: between(18, 65) }) 284 | * // WHERE age >= 18 AND age <= 65 285 | * 286 | * query.where({ age: between(18, 65, false) }) 287 | * // WHERE age > 18 < AND age < 65 288 | * 289 | * query.where({ age: between(18, 65, true, false) }) 290 | * // WHERE age >= 18 AND age < 65 291 | * 292 | * query.where({ age: between('lowerBound', 'upperBound', true, false, true) }) 293 | * // WHERE age >= lowerBound AND age < upperBound 294 | * ``` 295 | * 296 | * @param lower 297 | * @param upper 298 | * @param {boolean} lowerInclusive 299 | * @param {boolean} upperInclusive 300 | * @param {boolean} variables 301 | * @returns {Comparator} 302 | */ 303 | export function between( 304 | lower: any, 305 | upper: any, 306 | lowerInclusive = true, 307 | upperInclusive = lowerInclusive, 308 | variables?: boolean, 309 | ): Comparator { 310 | const lowerOp = lowerInclusive ? '>=' : '>'; 311 | const upperOp = upperInclusive ? '<=' : '<'; 312 | return (params: ParameterBag, name) => { 313 | const paramName = capitalize(name); 314 | const lowerComparator = compare(lowerOp, lower, variables, `lower${paramName}`); 315 | const upperComparator = compare(upperOp, upper, variables, `upper${paramName}`); 316 | 317 | const lowerConstraint = lowerComparator(params, name); 318 | const upperConstraint = upperComparator(params, name); 319 | return `${lowerConstraint} AND ${upperConstraint}`; 320 | }; 321 | } 322 | 323 | /** 324 | * Is null comparator for use in where clauses. Note that this comparator does 325 | * not accept any arguments 326 | * 327 | * ``` 328 | * query.where({ name: isNull() }) 329 | * // WHERE name IS NULL 330 | * ``` 331 | * @returns {Comparator} 332 | */ 333 | export function isNull(): Comparator { 334 | return (params, name) => `${name} IS NULL`; 335 | } 336 | 337 | /** 338 | * Has label comparator for use in where clauses. 339 | * 340 | * ``` 341 | * query.where({ person: hasLabel('Manager') }) 342 | * // WHERE person:Manager 343 | * ``` 344 | * @param {string} label 345 | * @returns {Comparator} 346 | */ 347 | export function hasLabel(label: string): Comparator { 348 | return (params, name) => `${name}:${label}`; 349 | } 350 | 351 | /** 352 | * Exists comparator for use in where clauses. Note that this comparator does 353 | * not accept any arguments 354 | * 355 | * ``` 356 | * query.where({ person: exists() }) 357 | * // WHERE exists(person) 358 | * ``` 359 | * @returns {Comparator} 360 | */ 361 | export function exists(): Comparator { 362 | return (params, name) => `exists(${name})`; 363 | } 364 | -------------------------------------------------------------------------------- /src/clauses/where-operators.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | and, WhereAnd, WhereNot, WhereOr, 3 | WhereXor, xor, not, or, 4 | } from './where-operators'; 5 | import { expect } from 'chai'; 6 | import { ParameterBag } from '../parameter-bag'; 7 | 8 | describe('WhereAnd', () => { 9 | it('should combine conditions with AND', () => { 10 | const op = new WhereAnd({ name: 1, name2: 2, node: { prop: 3 } }); 11 | expect(op.evaluate(new ParameterBag())) 12 | .to.equal('name = $name AND name2 = $name2 AND node.prop = $prop'); 13 | }); 14 | 15 | describe('#and', () => { 16 | it('should return a WhereAnd instance', () => { 17 | expect(and({ a: 'b' })).to.be.an.instanceof(WhereAnd); 18 | }); 19 | }); 20 | }); 21 | 22 | describe('WhereOr', () => { 23 | it('should combine conditions with OR', () => { 24 | const op = new WhereOr([{ name: 1 }, { name2: 2 }, { node: { prop: 3 } }]); 25 | expect(op.evaluate(new ParameterBag())) 26 | .to.equal('name = $name OR name2 = $name2 OR node.prop = $prop'); 27 | }); 28 | 29 | describe('#or', () => { 30 | it('should return a WhereOr instance', () => { 31 | expect(or([{ a: 'b' }])).to.be.an.instanceof(WhereOr); 32 | }); 33 | }); 34 | }); 35 | 36 | describe('WhereXor', () => { 37 | it('should combine conditions with XOR', () => { 38 | const op = new WhereXor([{ name: 1 }, { name2: 2 }, { node: { prop: 3 } }]); 39 | expect(op.evaluate(new ParameterBag())) 40 | .to.equal('name = $name XOR name2 = $name2 XOR node.prop = $prop'); 41 | }); 42 | 43 | describe('#or', () => { 44 | it('should return a WhereOr instance', () => { 45 | expect(xor([{ a: 'b' }])).to.be.an.instanceof(WhereXor); 46 | }); 47 | }); 48 | }); 49 | 50 | describe('WhereNot', () => { 51 | it('should combine conditions with NOT', () => { 52 | const op = new WhereNot([{ name: 1, name2: 2 }, { node: { prop: 3 } }]); 53 | expect(op.evaluate(new ParameterBag())) 54 | .to.equal('NOT (name = $name AND name2 = $name2 OR node.prop = $prop)'); 55 | }); 56 | 57 | describe('#or', () => { 58 | it('should return a WhereNot instance', () => { 59 | expect(not({ a: 'b' })).to.be.an.instanceof(WhereNot); 60 | }); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /src/clauses/where-operators.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AndConditions, 3 | AnyConditions, 4 | combineAnd, 5 | combineNot, 6 | combineOr, 7 | combineXor, 8 | OrConditions, 9 | Precedence, 10 | WhereOp, 11 | } from './where-utils'; 12 | import { ParameterBag } from '../parameter-bag'; 13 | 14 | export const operators = { and, or, xor, not }; 15 | 16 | /** 17 | * `AND` operator to use in where clauses. This is the default operator when 18 | * using conditions so you will probably never need to use this unless you'd 19 | * like to make it explicit. 20 | * 21 | * ``` 22 | * query.where(and({ 23 | * 'person.name': 'Steve', 24 | * 'person.age': greaterThan(18), 25 | * })); 26 | * // WHERE person.name = 'Steve' AND person.age > 18 27 | * ``` 28 | * Note that this method only accepts a dictionary of conditions. 29 | * 30 | * @param {AndConditions} conditions 31 | * @returns {WhereAnd} 32 | */ 33 | export function and(conditions: AndConditions) { 34 | return new WhereAnd(conditions); 35 | } 36 | 37 | export class WhereAnd extends WhereOp { 38 | constructor(protected conditions: AndConditions) { 39 | super(); 40 | } 41 | 42 | evaluate(params: ParameterBag, precedence = Precedence.None, name = '') { 43 | return combineAnd(params, this.conditions, precedence, name); 44 | } 45 | } 46 | 47 | /** 48 | * `OR` operator to use in where clauses. This is the default operator when 49 | * supplying an array to where so you will probably never need to use this 50 | * unless you'd like to make it explicit. 51 | * 52 | * ``` 53 | * query.where(or([ 54 | * { 'person.name': 'Steve' }, 55 | * { 'person.age': greaterThan(18) }, 56 | * ])); 57 | * // WHERE person.name = 'Steve' OR person.age > 18 58 | * ``` 59 | * Note that this method only accepts an array of conditions. 60 | * 61 | * @param {OrConditions} conditions 62 | * @returns {WhereOr} 63 | */ 64 | export function or(conditions: OrConditions) { 65 | return new WhereOr(conditions); 66 | } 67 | 68 | export class WhereOr extends WhereOp { 69 | constructor(protected conditions: OrConditions) { 70 | super(); 71 | } 72 | 73 | evaluate(params: ParameterBag, precedence = Precedence.None, name = '') { 74 | return combineOr(params, this.conditions, precedence, name); 75 | } 76 | } 77 | 78 | /** 79 | * `XOR` operator to use in where clauses. 80 | * 81 | * ``` 82 | * query.where(xor([ 83 | * { 'person.name': 'Steve' }, 84 | * { 'person.age': greaterThan(18) }, 85 | * ])); 86 | * // WHERE person.name = 'Steve' XOR person.age > 18 87 | * ``` 88 | * Note that this method only accepts an array of conditions. 89 | * 90 | * @param {OrConditions} conditions 91 | * @returns {WhereXor} 92 | */ 93 | export function xor(conditions: OrConditions) { 94 | return new WhereXor(conditions); 95 | } 96 | 97 | export class WhereXor extends WhereOp { 98 | constructor(protected conditions: OrConditions) { 99 | super(); 100 | } 101 | 102 | evaluate(params: ParameterBag, precedence = Precedence.None, name = '') { 103 | return combineXor(params, this.conditions, precedence, name); 104 | } 105 | } 106 | 107 | /** 108 | * `NOT` operator to use in where clauses. 109 | * 110 | * ``` 111 | * query.where(not([ 112 | * { 'person.name': 'Steve' }, 113 | * { 'person.age': greaterThan(18) }, 114 | * ])); 115 | * // WHERE NOT (person.name = 'Steve' AND person.age > 18) 116 | * ``` 117 | * Note that this method only accepts an array of conditions. 118 | * 119 | * @param {OrConditions} conditions 120 | * @returns {WhereXor} 121 | */ 122 | export function not(conditions: AnyConditions) { 123 | return new WhereNot(conditions); 124 | } 125 | 126 | export class WhereNot extends WhereOp { 127 | constructor(protected conditions: AnyConditions) { 128 | super(); 129 | } 130 | 131 | evaluate(params: ParameterBag, precedence = Precedence.None, name = '') { 132 | return combineNot(params, this.conditions, precedence, name); 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/clauses/where-utils.spec.ts: -------------------------------------------------------------------------------- 1 | import { AnyConditions, stringCons } from './where-utils'; 2 | import { expect } from 'chai'; 3 | import { ParameterBag } from '../parameter-bag'; 4 | import { not, xor } from './where-operators'; 5 | import { equals, greaterThan } from './where-comparators'; 6 | 7 | describe('stringifyConditions', () => { 8 | function stringify(conditions: AnyConditions) { 9 | return stringCons(new ParameterBag(), conditions); 10 | } 11 | 12 | it('should convert a simple object', () => { 13 | expect(stringify({ name: 'value' })).to.equal('name = $name'); 14 | }); 15 | 16 | it('should join arrays of values with OR', () => { 17 | expect(stringify({ name: ['value1', 'value2'] })) 18 | .to.equal('name = $name OR name = $name2'); 19 | }); 20 | 21 | it('should join many conditions with AND', () => { 22 | expect(stringify({ name: 'value1', name2: 'value2' })) 23 | .to.equal('name = $name AND name2 = $name2'); 24 | }); 25 | 26 | it('should join arrays of conditions with OR', () => { 27 | expect(stringify([ 28 | { name: 'value' }, 29 | { name2: 'value2' }, 30 | ])).to.equal('name = $name OR name2 = $name2'); 31 | }); 32 | 33 | it('should support node conditions', () => { 34 | expect(stringify({ 35 | node: { 36 | name: 'value1', 37 | name2: 'value2', 38 | }, 39 | })).to.equal('node.name = $name AND node.name2 = $name2'); 40 | }); 41 | 42 | it('should join arrays of node conditions with OR', () => { 43 | expect(stringify({ 44 | node: [ 45 | { name: 'value1' }, 46 | { name: 'value2' }, 47 | ], 48 | })).to.equal('node.name = $name OR node.name = $name2'); 49 | }); 50 | 51 | it('should join arrays of node values with OR', () => { 52 | expect(stringify({ 53 | node: { 54 | name: ['value1', 'value2'], 55 | }, 56 | })).to.equal('node.name = $name OR node.name = $name2'); 57 | }); 58 | 59 | it('should join conditions with XOR', () => { 60 | expect(stringify(xor([{ name: 'value' }, { name2: 'value' }]))) 61 | .to.equal('name = $name XOR name2 = $name2'); 62 | }); 63 | 64 | it('should negate conditions with NOT', () => { 65 | expect(stringify(not([{ name: 'value' }, { name2: 'value' }]))) 66 | .to.equal('NOT (name = $name OR name2 = $name2)'); 67 | }); 68 | 69 | it('should preserve order of operations', () => { 70 | expect(stringify([ 71 | { 72 | name: 'value', 73 | }, 74 | { 75 | name2: 'value', 76 | name3: ['value', 'value'], 77 | }, 78 | ])).to.equal('name = $name OR name2 = $name2 AND (name3 = $name3 OR name3 = $name4)'); 79 | }); 80 | 81 | it('should support comparators', () => { 82 | expect(stringify({ 83 | name: equals('value'), 84 | age: greaterThan(20), 85 | })).to.equal('name = $name AND age > $age'); 86 | }); 87 | }); 88 | -------------------------------------------------------------------------------- /src/clauses/where-utils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Dictionary, 3 | isPlainObject, 4 | Many, 5 | isArray, 6 | map, 7 | last, 8 | keys, 9 | isFunction, 10 | isRegExp, 11 | } from 'lodash'; 12 | import { ParameterBag } from '../parameter-bag'; 13 | import { Comparator, regexp } from './where-comparators'; 14 | 15 | export type Condition = any | Comparator; 16 | export type Conditions = Dictionary>; 17 | export type NodeConditions = Dictionary>; 18 | export type AnyConditions = Many; 19 | export type AndConditions = NodeConditions | Conditions; 20 | export type OrConditions = (NodeConditions | Conditions | Condition)[]; 21 | 22 | export const enum Precedence { 23 | None, 24 | Or, 25 | Xor, 26 | And, 27 | Not, 28 | } 29 | 30 | export abstract class WhereOp { 31 | abstract evaluate(params: ParameterBag, precedence?: Precedence, name?: string): string; 32 | } 33 | 34 | export function stringifyCondition( 35 | params: ParameterBag, 36 | condition: Condition, 37 | name: string = '', 38 | ): string { 39 | if (isFunction(condition)) { 40 | return condition(params, name); 41 | } 42 | const conditionName = last(name.split('.')); 43 | return `${name} = ${params.addParam(condition, conditionName)}`; 44 | } 45 | 46 | export function stringCons( 47 | params: ParameterBag, 48 | conditions: Many, 49 | precedence: Precedence = Precedence.None, 50 | name: string = '', 51 | ): string { 52 | if (isArray(conditions)) { 53 | return combineOr(params, conditions, precedence, name); 54 | } 55 | if (isPlainObject(conditions)) { 56 | return combineAnd(params, conditions, precedence, name); 57 | } 58 | if (conditions instanceof WhereOp) { 59 | return conditions.evaluate(params, precedence, name); 60 | } 61 | if (isRegExp(conditions)) { 62 | return stringifyCondition(params, regexp(conditions), name); 63 | } 64 | return stringifyCondition(params, conditions, name); 65 | } 66 | 67 | export function combineNot( 68 | params: ParameterBag, 69 | conditions: AnyConditions, 70 | precedence: Precedence = Precedence.None, 71 | name: string = '', 72 | ): string { 73 | const string = `NOT ${stringCons(params, conditions, Precedence.Not, name)}`; 74 | const braces = precedence !== Precedence.None && precedence > Precedence.Not; 75 | return braces ? `(${string})` : string; 76 | } 77 | 78 | export function combineOr( 79 | params: ParameterBag, 80 | conditions: OrConditions, 81 | precedence: Precedence = Precedence.None, 82 | name: string = '', 83 | ): string { 84 | // If this operator will not be used, precedence should not be altered 85 | const newPrecedence = conditions.length < 2 ? precedence : Precedence.Or; 86 | const strings = map(conditions, condition => stringCons(params, condition, newPrecedence, name)); 87 | 88 | const string = strings.join(' OR '); 89 | const braces = precedence !== Precedence.None && precedence > newPrecedence; 90 | return braces ? `(${string})` : string; 91 | } 92 | 93 | export function combineXor( 94 | params: ParameterBag, 95 | conditions: OrConditions, 96 | precedence: Precedence = Precedence.None, 97 | name: string = '', 98 | ): string { 99 | // If this operator will not be used, precedence should not be altered 100 | const newPrecedence = conditions.length < 2 ? precedence : Precedence.Xor; 101 | const strings = map(conditions, condition => stringCons(params, condition, newPrecedence, name)); 102 | 103 | const string = strings.join(' XOR '); 104 | const braces = precedence !== Precedence.None && precedence > newPrecedence; 105 | return braces ? `(${string})` : string; 106 | } 107 | 108 | export function combineAnd( 109 | params: ParameterBag, 110 | conditions: AndConditions, 111 | precedence: Precedence = Precedence.None, 112 | name: string = '', 113 | ): string { 114 | // Prepare name to be joined with the key of the object 115 | const namePrefix = name.length > 0 ? `${name}.` : ''; 116 | 117 | // If this operator will not be used, precedence should not be altered 118 | const newPrecedence = keys(conditions).length < 2 ? precedence : Precedence.And; 119 | const strings = map(conditions, (condition, key) => { 120 | return stringCons(params, condition, newPrecedence, namePrefix + key); 121 | }); 122 | 123 | const string = strings.join(' AND '); 124 | const braces = precedence !== Precedence.None && precedence > newPrecedence; 125 | return braces ? `(${string})` : string; 126 | } 127 | -------------------------------------------------------------------------------- /src/clauses/where.spec.ts: -------------------------------------------------------------------------------- 1 | import { Where } from './where'; 2 | import { expect } from 'chai'; 3 | import { between, lessThan } from './where-comparators'; 4 | import { Query } from '../query'; 5 | import { node } from './index'; 6 | 7 | describe('Where', () => { 8 | describe('#build', () => { 9 | it('should start with WHERE', () => { 10 | const query = new Where({ name: 'value' }); 11 | expect(query.build()).to.equal('WHERE name = $name'); 12 | }); 13 | 14 | it('should compile with a comparator', () => { 15 | const query = new Where({ age: between(18, 65) }); 16 | expect(query.build()).to.equal('WHERE age >= $lowerAge AND age <= $upperAge'); 17 | expect(query.getParams()).to.deep.equal({ 18 | lowerAge: 18, 19 | upperAge: 65, 20 | }); 21 | }); 22 | 23 | it('should not produce duplicate parameter names', () => { 24 | const nodePattern = node('person', 'Person', { id: 1 }); 25 | const query = new Query().match([nodePattern]) 26 | .where({ id: lessThan(10) }); 27 | const { params, query: queryString } = query.buildQueryObject(); 28 | expect(queryString).to.equal(`MATCH ${nodePattern.toString()}\nWHERE id < $id2;`); 29 | expect(params).to.deep.equal({ 30 | id: 1, 31 | id2: 10, 32 | }); 33 | }); 34 | 35 | it('should compile with a regular expression', () => { 36 | const query = new Where({ name: /[A-Z].*son/ }); 37 | expect(query.build()).to.equal('WHERE name =~ $name'); 38 | expect(query.getParams()).to.deep.equal({ 39 | name: '[A-Z].*son', 40 | }); 41 | }); 42 | 43 | it('should compile with a regular expression with flags', () => { 44 | const query = new Where({ name: /.*son/i }); 45 | expect(query.build()).to.equal('WHERE name =~ $name'); 46 | expect(query.getParams()).to.deep.equal({ 47 | name: '.*son', 48 | }); 49 | }); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /src/clauses/where.ts: -------------------------------------------------------------------------------- 1 | import { Clause } from '../clause'; 2 | import { AnyConditions, stringCons } from './where-utils'; 3 | 4 | export class Where extends Clause { 5 | constructor(public conditions: AnyConditions) { 6 | super(); 7 | } 8 | 9 | build() { 10 | return `WHERE ${stringCons(this.parameterBag, this.conditions)}`; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/clauses/with.spec.ts: -------------------------------------------------------------------------------- 1 | import { With } from './with'; 2 | import { expect } from 'chai'; 3 | 4 | describe('With', () => { 5 | it('should start with WITH', () => { 6 | const clause = new With('node'); 7 | expect(clause.build()).to.equal('WITH node'); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /src/clauses/with.ts: -------------------------------------------------------------------------------- 1 | import { Many } from 'lodash'; 2 | import { Term, TermListClause } from './term-list-clause'; 3 | 4 | export class With extends TermListClause { 5 | /** 6 | * Creates a with clause 7 | * @param {string|object|array} terms 8 | */ 9 | constructor(terms: Many) { 10 | super(terms); 11 | } 12 | 13 | build() { 14 | return `WITH ${super.build()}`; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/connection.ts: -------------------------------------------------------------------------------- 1 | import { Dictionary, isFunction } from 'lodash'; 2 | import nodeCleanup from 'node-cleanup'; 3 | import { AuthToken, Config, Driver, Session } from 'neo4j-driver/types'; 4 | import * as neo4j from 'neo4j-driver'; 5 | import { Transformer } from './transformer'; 6 | import { Query } from './query'; 7 | import { Builder } from './builder'; 8 | import { Clause } from './clause'; 9 | import { Observable } from 'rxjs'; 10 | 11 | let connections: Connection[] = []; 12 | 13 | // Closes all open connections 14 | nodeCleanup(() => { 15 | connections.forEach(con => con.close()); 16 | connections = []; 17 | }); 18 | 19 | export interface Observer { 20 | closed?: boolean; 21 | next: (value: T) => void; 22 | error: (error: any) => void; 23 | complete: () => void; 24 | } 25 | 26 | export type DriverConstructor = typeof neo4j.driver; 27 | 28 | export interface FullConnectionOptions { 29 | driverConstructor: DriverConstructor; 30 | driverConfig: Config; 31 | } 32 | 33 | export type ConnectionOptions = Partial; 34 | 35 | export interface Credentials { username: string; password: string; } 36 | 37 | function isCredentials(credentials: any): credentials is Credentials { 38 | return 'username' in credentials && 'password' in credentials; 39 | } 40 | 41 | // We have to correct the type of lodash's isFunction method because it doesn't correctly narrow 42 | // union types such as the options parameter passed to the connection constructor. 43 | const isTrueFunction: (value: any) => value is Function = isFunction; 44 | 45 | // tslint:disable max-line-length 46 | /** 47 | * The Connection class lets you access the Neo4j server and run queries against it. Under the hood, 48 | * the Connection class uses the official Neo4j Nodejs driver which manages connection pooling on a 49 | * [session basis]{@link https://neo4j.com/docs/api/javascript-driver/current/class/src/v1/driver.js~Driver.html#instance-method-session}. 50 | * It should be enough to have a single Connection instance per database per application. 51 | * 52 | * To create the connection, simply call the 53 | * [constructor]{@link https://jamesfer.me/cypher-query-builder/classes/connection.html#constructor} 54 | * and pass in the database url, username and password. 55 | * ``` 56 | * const db = new Connection('bolt://localhost', { 57 | * username: 'neo4j', 58 | * password: 'password', 59 | * }) 60 | * ``` 61 | * 62 | * To use the connection, just start calling any of the clause methods such as `match`, `create` or 63 | * `matchNode` etc. They automatically create a {@link Query} object that you can then chain other 64 | * methods off of. 65 | * ``` 66 | * db.matchNode('people', 'Person') 67 | * .where({ 'people.age': greaterThan(18) }) 68 | * .return('people') 69 | * .run() 70 | * ``` 71 | * 72 | * You can also pass a query to the 73 | * [run]{@link https://jamesfer.me/cypher-query-builder/classes/connection.html#run} method, 74 | * however, this is probably much less convenient. 75 | * ``` 76 | * db.run( 77 | * new Query().matchNode('people', 'Person') 78 | * .where({ 'people.age': greaterThan(18) }) 79 | * .return('people') 80 | * .run() 81 | * ); 82 | * ``` 83 | * 84 | * Once you've finished with the connection you should close the connection. 85 | * ``` 86 | * db.close() 87 | * ``` 88 | * 89 | * The library will attempt to clean up all connections when the process exits, but it is better to 90 | * be explicit. 91 | */ 92 | // tslint:enable max-line-length 93 | export class Connection extends Builder { 94 | protected auth: AuthToken; 95 | protected driver: Driver; 96 | protected options: FullConnectionOptions; 97 | protected open: boolean; 98 | protected transformer = new Transformer(); 99 | 100 | /** 101 | * Creates a new connection to the database. 102 | * 103 | * @param url Url of the database such as `'bolt://localhost'` 104 | * @param auth Auth can either be an object in the form `{ username: ..., password: ... }`, or a 105 | * Neo4j AuthToken object which contains the `scheme`, `principal` and `credentials` properties 106 | * for more advanced authentication scenarios. The AuthToken object is what is passed directly to 107 | * the neo4j javascript driver so checkout their docs for more information on it. 108 | * @param options Additional configuration options. If you provide a function instead of an 109 | * object, it will be used as the driver constructor. While passing a driver constructor function 110 | * here is not deprecated, it is the legacy way of setting it and you should prefer to pass an 111 | * options object with the `driverConstructor` parameter. 112 | * @param options.driverConstructor An optional driver constructor to use for 113 | * this connection. Defaults to the official Neo4j driver. The constructor is 114 | * given the url you pass to this constructor and an auth token that is 115 | * generated from calling [`neo4j.auth.basic`]{@link 116 | * https://neo4j.com/docs/api/javascript-driver/current#usage-examples}. 117 | * @param options.driverConfig Neo4j options that are passed directly to the underlying driver. 118 | */ 119 | constructor( 120 | protected url: string, 121 | auth: Credentials | AuthToken, 122 | options: DriverConstructor | ConnectionOptions = neo4j.driver, 123 | ) { 124 | super(); 125 | 126 | this.auth = isCredentials(auth) 127 | ? neo4j.auth.basic(auth.username, auth.password) 128 | : auth; 129 | 130 | const driverConstructor = isTrueFunction(options) ? options 131 | : options.driverConstructor ? options.driverConstructor : neo4j.driver; 132 | const driverConfig = isTrueFunction(options) || !options.driverConfig 133 | ? {} : options.driverConfig; 134 | this.options = { driverConstructor, driverConfig }; 135 | this.driver = driverConstructor(this.url, this.auth, this.options.driverConfig); 136 | this.open = true; 137 | connections.push(this); 138 | } 139 | 140 | /** 141 | * Closes this connection if it is open. Closed connections cannot be 142 | * reopened. 143 | */ 144 | async close(): Promise { 145 | if (this.open) { 146 | await this.driver.close(); 147 | this.open = false; 148 | } 149 | } 150 | 151 | /** 152 | * Opens and returns a session. You should never need to use this directly. 153 | * Your probably better off with `run` instead. 154 | */ 155 | session(): Session | null { 156 | if (this.open) { 157 | return this.driver.session(); 158 | } 159 | return null; 160 | } 161 | 162 | /** 163 | * Returns a new query that uses this connection. The methods such as `match` 164 | * or `create` are probably more useful to you as they automatically create a 165 | * new chainable query for you. 166 | * @return {Query} 167 | */ 168 | query(): Query { 169 | return new Query(this); 170 | } 171 | 172 | protected continueChainClause(clause: Clause) { 173 | return this.query().addClause(clause); 174 | } 175 | 176 | /** 177 | * Runs the provided query on this connection, regardless of which connection 178 | * the query was created from. Each query is run on it's own session. 179 | * 180 | * Run returns a promise that resolves to an array of records. Each key of the 181 | * record is the name of a variable that you specified in your `RETURN` 182 | * clause. 183 | * Eg: 184 | * ```typescript 185 | * connection.match([ 186 | * node('steve', { name: 'Steve' }), 187 | * relation('out', [ 'FriendsWith' ]), 188 | * node('friends'), 189 | * ]) 190 | * .return([ 'steve', 'friends' ]) 191 | * .run(); 192 | * ``` 193 | * 194 | * Would result in the value: 195 | * ``` 196 | * [ 197 | * { 198 | * steve: { ... } // steve node, 199 | * friends: { ... } // first friend, 200 | * }, 201 | * { 202 | * steve: { ... } // steve node, 203 | * friends: { ... } // second friend, 204 | * }, 205 | * { 206 | * steve: { ... } // steve node, 207 | * friends: { ... } // third friend, 208 | * }, 209 | * ] 210 | * ``` 211 | * 212 | * Notice how the steve record is returned for each row, this is how cypher 213 | * works. If you use lodash you can extract all of Steve's friends from the 214 | * results like using `_.map(results, 'friends')`. If you don't, you can use 215 | * ES2015/ES6: `results.map(record => record.friends)`. 216 | * 217 | * If you use typescript you can use the type parameter to hint at the type of 218 | * the return value which is `Dictionary[]`. 219 | * 220 | * Throws an exception if this connection is not open or there are no clauses 221 | * in the query. 222 | * 223 | * @param {Query} query 224 | * @returns {Promise[]>} 225 | */ 226 | async run(query: Query): Promise[]> { 227 | if (!this.open) { 228 | throw new Error('Cannot run query; connection is not open.'); 229 | } 230 | 231 | if (query.getClauses().length === 0) { 232 | throw new Error('Cannot run query: no clauses attached to the query.'); 233 | } 234 | 235 | const session = this.session(); 236 | if (!session) { 237 | throw new Error('Cannot run query: connection is not open.'); 238 | } 239 | 240 | const queryObj = query.buildQueryObject(); 241 | 242 | return session.run(queryObj.query, queryObj.params) 243 | .then( 244 | async ({ records }) => { 245 | await session.close(); 246 | return this.transformer.transformRecords(records); 247 | }, 248 | async (error) => { 249 | await session.close(); 250 | throw error; 251 | }, 252 | ); 253 | } 254 | 255 | /** 256 | * Runs the provided query on this connection, regardless of which connection 257 | * the query was created from. Each query is run on it's own session. 258 | * 259 | * Returns an RxJS observable that emits each record as it is received from the 260 | * database. This is the most efficient way of working with very large 261 | * datasets. Each record is an object where each key is the name of a variable 262 | * that you specified in your return clause. 263 | * 264 | * Eg: 265 | * ```typescript 266 | * const results$ = connection.match([ 267 | * node('steve', { name: 'Steve' }), 268 | * relation('out', [ 'FriendsWith' ]), 269 | * node('friends'), 270 | * ]) 271 | * .return([ 'steve', 'friends' ]) 272 | * .stream(); 273 | * 274 | * // Emits 275 | * // { 276 | * // steve: { ... } // steve node, 277 | * // friends: { ... } // first friend, 278 | * // }, 279 | * // Then emits 280 | * // { 281 | * // steve: { ... } // steve node, 282 | * // friends: { ... } // first friend, 283 | * // }, 284 | * // And so on 285 | * ``` 286 | * 287 | * Notice how the steve record is returned for each row, this is how cypher 288 | * works. You can extract all of steve's friends from the query by using 289 | * operators: 290 | * ``` 291 | * const friends$ = results$.map(row => row.friends); 292 | * ``` 293 | * 294 | * If you use typescript you can use the type parameter to hint at the type of 295 | * the return value which is `Dictionary`. 296 | * 297 | * Throws an exception if this connection is not open or there are no clauses 298 | * in the query. 299 | * 300 | * The query is run when you call stream so you should subscribe to the results 301 | * immediately to prevent missing any data. 302 | * 303 | * Due to the way the Neo4j javascript driver works, once you call stream there 304 | * is no way to stop the query until it is complete. Even if you unsubscribe from 305 | * the observable, all the remaining rows will still be parsed by the driver but 306 | * then immediately discarded. 307 | * ```typescript 308 | * const results$ = connection.matchNode('records') 309 | * .return('records') 310 | * .limit(1000) // 1000 records will be loaded and parsed from the database 311 | * .stream() 312 | * .take(10) // even though you only take the first 10 313 | * .subscribe(record => {}); 314 | * ``` 315 | * In practice this should never happen unless you're doing some strange things. 316 | */ 317 | stream(query: Query): Observable> { 318 | return new Observable((subscriber: Observer>): void => { 319 | if (!this.open) { 320 | subscriber.error(new Error('Cannot run query: connection is not open.')); 321 | return; 322 | } 323 | 324 | if (query.getClauses().length === 0) { 325 | subscriber.error(new Error('Cannot run query: no clauses attached to the query.')); 326 | return; 327 | } 328 | 329 | const session = this.session(); 330 | if (!session) { 331 | subscriber.error(new Error('Cannot run query: connection is not open.')); 332 | return; 333 | } 334 | 335 | // Run the query 336 | const queryObj = query.buildQueryObject(); 337 | const result = session.run(queryObj.query, queryObj.params); 338 | 339 | // Subscribe to the result and clean up the session 340 | // Note: Neo4j observables use a different subscribe syntax to RxJS observables 341 | result.subscribe({ 342 | onNext: (record) => { 343 | if (!subscriber.closed) { 344 | subscriber.next(this.transformer.transformRecord(record)); 345 | } 346 | }, 347 | onError: async (error) => { 348 | await session.close(); 349 | if (!subscriber.closed) { 350 | subscriber.error(error); 351 | } 352 | }, 353 | onCompleted: async () => { 354 | await session.close(); 355 | if (!subscriber.closed) { 356 | subscriber.complete(); 357 | } 358 | }, 359 | }); 360 | }); 361 | } 362 | } 363 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | export * from './builder'; 3 | export * from './connection'; 4 | export * from './clause'; 5 | export * from './clause-collection'; 6 | export * from './clauses'; 7 | export * from './query'; 8 | export * from './transformer'; 9 | -------------------------------------------------------------------------------- /src/parameter-bag.spec.ts: -------------------------------------------------------------------------------- 1 | import { keys, values } from 'lodash'; 2 | import { ParameterBag } from './parameter-bag'; 3 | import { expect } from '../test-setup'; 4 | 5 | describe('ParameterBag', () => { 6 | let parameterBag: ParameterBag; 7 | 8 | beforeEach(() => { 9 | parameterBag = new ParameterBag(); 10 | }); 11 | 12 | describe('#getName', () => { 13 | it('should return unique names', () => { 14 | parameterBag.addParam('1', 'name'); 15 | parameterBag.addParam('2', 'name2'); 16 | parameterBag.addParam('3', 'name3'); 17 | expect(parameterBag.getName()).to.equal('p'); 18 | expect(parameterBag.getName('name')).to.equal('name4'); 19 | }); 20 | }); 21 | 22 | describe('#getParams', () => { 23 | it('should return an empty object for new clauses', () => { 24 | expect(parameterBag.getParams()).to.be.empty; 25 | }); 26 | 27 | it('should return an object of parameters', () => { 28 | parameterBag.addParam('a', 'a'); 29 | parameterBag.addParam('b', 'b'); 30 | expect(parameterBag.getParams()).to.eql({ 31 | a: 'a', 32 | b: 'b', 33 | }); 34 | }); 35 | 36 | it('should automatically generate a unique name if one is not provided', () => { 37 | parameterBag.addParam('hello'); 38 | parameterBag.addParam('world'); 39 | const paramObj = parameterBag.getParams(); 40 | expect(keys(paramObj)).to.have.length(2); 41 | expect(values(paramObj)).to.contain('hello'); 42 | expect(values(paramObj)).to.contain('world'); 43 | }); 44 | }); 45 | 46 | describe('#addParam', () => { 47 | it('should create and add a parameter to the bag', () => { 48 | const param = parameterBag.addParam('value', 'name'); 49 | expect(param.name).to.equal('name'); 50 | expect(param.value).to.equal('value'); 51 | expect(parameterBag.getParams()).to.have.property('name', 'value'); 52 | }); 53 | 54 | it('should generate a name if one is not given', () => { 55 | parameterBag.addParam('value'); 56 | expect(values(parameterBag.getParams())).to.contain('value'); 57 | }); 58 | 59 | it.skip('should rename the parameter if the given name is taken', () => { 60 | parameterBag.addParam('value', 'name'); 61 | parameterBag.addParam('value2', 'name'); 62 | const paramObj = parameterBag.getParams(); 63 | expect(keys(paramObj)).to.have.length(2); 64 | expect(values(paramObj)).to.contain('value'); 65 | expect(values(paramObj)).to.contain('value2'); 66 | }); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /src/parameter-bag.ts: -------------------------------------------------------------------------------- 1 | import { Dictionary, keys, mapValues } from 'lodash'; 2 | import { uniqueString } from './utils'; 3 | 4 | export class Parameter { 5 | constructor( 6 | public name: string, 7 | public value: string, 8 | ) { } 9 | 10 | toString() { 11 | return `$${this.name}`; 12 | } 13 | } 14 | 15 | export class ParameterBag { 16 | protected parameterMap: Dictionary = {}; 17 | 18 | /** 19 | * Constructs a unique name for this parameter bag. 20 | * @return {string} 21 | */ 22 | getName(name = 'p') { 23 | return uniqueString(name, keys(this.parameterMap)); 24 | } 25 | 26 | /** 27 | * Adds a new parameter to this bag. 28 | * @param {*} value 29 | * @param {string|undefined} name 30 | * @return {Parameter} Newly created parameter object. 31 | */ 32 | addParam(value: any, name?: string) { 33 | const actualName = this.getName(name); 34 | const param = new Parameter(actualName, value); 35 | this.parameterMap[actualName] = param; 36 | return param; 37 | } 38 | 39 | /** 40 | * Adds an existing parameter to this bag. The name may be changed if 41 | * it is already taken, however, the Parameter object will not be recreated. 42 | * @param {Parameter} param 43 | * @return {Parameter} 44 | */ 45 | addExistingParam(param: Parameter) { 46 | param.name = this.getName(param.name); 47 | this.parameterMap[param.name] = param; 48 | return param; 49 | } 50 | 51 | /** 52 | * Returns the params in a name: value object suitable for putting into a 53 | * query object. 54 | * @return {object} 55 | */ 56 | getParams(): Dictionary { 57 | return mapValues(this.parameterMap, 'value'); 58 | } 59 | 60 | /** 61 | * Removes a parameter from the internal map. 62 | * @param {string} name 63 | */ 64 | deleteParam(name: string) { 65 | delete this.parameterMap[name]; 66 | } 67 | 68 | /** 69 | * Copies all parameters from another bag into this bag. 70 | */ 71 | importParams(other: ParameterBag) { 72 | for (const key in other.parameterMap) { 73 | this.addExistingParam(other.parameterMap[key]); 74 | } 75 | } 76 | 77 | /** 78 | * Returns a parameter with the given name. 79 | */ 80 | getParam(name: string) { 81 | return this.parameterMap[name]; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/parameter-container.spec.ts: -------------------------------------------------------------------------------- 1 | import { ParameterContainer } from './parameter-container'; 2 | import { ParameterBag } from './parameter-bag'; 3 | import { expect } from '../test-setup'; 4 | 5 | describe('ParameterContainer', () => { 6 | describe('#useParameterBag', () => { 7 | // it('should store the new bag in the class', function() { 8 | // let bag = new ParameterBag(); 9 | // let container = new ParameterContainer(); 10 | // expect(container.parameterBag).to.not.equal(bag); 11 | // container.useParameterBag(bag); 12 | // expect(container.parameterBag).to.equal(bag); 13 | // }); 14 | 15 | it('should merge properties from the existing bag to the new one', () => { 16 | const container = new ParameterContainer(); 17 | container.addParam('container value', 'name'); 18 | 19 | const bag = new ParameterBag(); 20 | bag.addParam('bag value', 'name'); 21 | container.useParameterBag(bag); 22 | 23 | const params = container.getParams(); 24 | expect(params).to.have.property('name', 'bag value'); 25 | expect(params).to.have.property('name2', 'container value'); 26 | }); 27 | 28 | it('should not recreate the Parameter objects', () => { 29 | const container = new ParameterContainer(); 30 | const param = container.addParam('container value', 'name'); 31 | 32 | const bag = new ParameterBag(); 33 | bag.addParam('bag value', 'name'); 34 | container.useParameterBag(bag); 35 | 36 | expect(param.name).to.equal('name2'); 37 | expect(container.getParameterBag().getParam('name2')).to.equal(param); 38 | }); 39 | }); 40 | 41 | describe('#getParams', () => { 42 | it('should return the params', () => { 43 | const container = new ParameterContainer(); 44 | container.addParam('value1', 'string'); 45 | container.addParam(7, 'number'); 46 | 47 | expect(container.getParams()).to.deep.equal({ 48 | string: 'value1', 49 | number: 7, 50 | }); 51 | }); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /src/parameter-container.ts: -------------------------------------------------------------------------------- 1 | import { Parameter, ParameterBag } from './parameter-bag'; 2 | import { Dictionary } from 'lodash'; 3 | 4 | export class ParameterContainer { 5 | protected parameterBag = new ParameterBag(); 6 | 7 | useParameterBag(newBag: ParameterBag) { 8 | newBag.importParams(this.parameterBag); 9 | this.parameterBag = newBag; 10 | } 11 | 12 | getParams(): Dictionary { 13 | return this.parameterBag.getParams(); 14 | } 15 | 16 | /** 17 | * Adds a new parameter to the bag. 18 | * @param {*} value 19 | * @param {string|undefined} name 20 | * @return {Parameter} Newly created parameter object. 21 | */ 22 | addParam(value: any, name?: string): Parameter { 23 | return this.parameterBag.addParam(value, name); 24 | } 25 | 26 | getParameterBag() { 27 | return this.parameterBag; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/query.spec.ts: -------------------------------------------------------------------------------- 1 | import { Dictionary, each } from 'lodash'; 2 | import { spy, stub } from 'sinon'; 3 | import { expect } from '../test-setup'; 4 | import { ClauseCollection } from './clause-collection'; 5 | import { node, NodePattern } from './clauses'; 6 | import { Query } from './query'; 7 | import { Observable } from 'rxjs'; 8 | import { Connection } from './connection'; 9 | 10 | describe('Query', () => { 11 | describe('query methods', () => { 12 | const methods: Dictionary<(q: Query) => Query> = { 13 | create: q => q.create(new NodePattern('Node')), 14 | createNode: q => q.createNode('Node'), 15 | delete: q => q.delete('node'), 16 | detachDelete: q => q.detachDelete('node'), 17 | limit: q => q.limit(1), 18 | match: q => q.match(new NodePattern('Node')), 19 | matchNode: q => q.matchNode('Node'), 20 | merge: q => q.merge(node('name')), 21 | onCreateSet: q => q.onCreate.set({ labels: { a: 'Label' } }), 22 | onCreateSetLabels: q => q.onCreate.setLabels({ a: 'Label' }), 23 | onCreateSetValues: q => q.onCreate.setValues({ a: 'name' }), 24 | onCreateSetVariables: q => q.onCreate.setVariables({ a: 'steve' }), 25 | onMatchSet: q => q.onMatch.set({ labels: { a: 'Label' } }), 26 | onMatchSetLabels: q => q.onMatch.setLabels({ a: 'Label' }), 27 | onMatchSetValues: q => q.onMatch.setValues({ a: 'name' }), 28 | onMatchSetVariables: q => q.onMatch.setVariables({ a: 'steve' }), 29 | optionalMatch: q => q.optionalMatch(new NodePattern('Node')), 30 | orderBy: q => q.orderBy('name'), 31 | raw: q => q.raw('name'), 32 | return: q => q.return('node'), 33 | set: q => q.set({}, { merge: false }), 34 | setLabels: q => q.setLabels({}), 35 | setValues: q => q.setValues({}), 36 | setVariables: q => q.setVariables({}, false), 37 | skip: q => q.skip(1), 38 | union: q => q.union(), 39 | unionAll: q => q.unionAll(), 40 | unwind: q => q.unwind([1, 2, 3], 'number'), 41 | where: q => q.where([]), 42 | with: q => q.with('node'), 43 | }; 44 | 45 | each(methods, (fn, name) => { 46 | it(`${name} should return a chainable query object`, () => { 47 | const query = new Query(); 48 | expect(fn(query)).to.equal(query); 49 | expect(query.getClauses().length === 1); 50 | expect(query.build()).to.equal(`${query.getClauses()[0].build()};`); 51 | }); 52 | }); 53 | }); 54 | 55 | describe('proxied methods', () => { 56 | class TestQuery extends Query { 57 | getClauseCollection() { 58 | return this.clauses; 59 | } 60 | } 61 | 62 | const query = new TestQuery(); 63 | 64 | const methods: (keyof ClauseCollection)[] = [ 65 | 'build', 66 | 'toString', 67 | 'buildQueryObject', 68 | 'interpolate', 69 | 'getClauses', 70 | ]; 71 | 72 | methods.forEach((method) => { 73 | it(`should proxy the ${method} method to the clause collection`, () => { 74 | const methodSpy = spy(query.getClauseCollection(), method); 75 | (query as any)[method](); 76 | expect(methodSpy.calledOnce).to.be.true; 77 | methodSpy.restore(); 78 | }); 79 | }); 80 | }); 81 | 82 | describe('#run', () => { 83 | it('should reject the promise if there is no attached connection object', () => { 84 | const query = new Query(); 85 | return expect(query.run()).to.be.rejectedWith(Error, 'no connection object available'); 86 | }); 87 | 88 | it('should run the query on its connection', () => { 89 | const connection = makeConnection(); 90 | const runStub = stub(connection, 'run'); 91 | runStub.returns(Promise.resolve([{}])); 92 | const query = new Query(connection).raw('Query'); 93 | return expect(query.run()).to.be.fulfilled.then(() => { 94 | expect(runStub.calledOnce); 95 | }); 96 | }); 97 | }); 98 | 99 | describe('#stream', () => { 100 | it('should return an errored observable if there is no attached connection object', () => { 101 | const observable = new Query().stream(); 102 | expect(observable).to.be.an.instanceOf(Observable); 103 | observable.subscribe({ 104 | next: () => expect.fail(null, null, 'Observable should not emit anything'), 105 | error(error) { 106 | expect(error).to.be.instanceOf(Error); 107 | expect(error.message).to.include('no connection object available'); 108 | }, 109 | complete: () => expect.fail(null, null, 'Observable should not complete successfully'), 110 | }); 111 | }); 112 | 113 | it('should run the query on its connection', () => { 114 | const connection = makeConnection(); 115 | const streamStub = stub(connection, 'stream'); 116 | const query = new Query(connection).raw('Query'); 117 | query.stream(); 118 | expect(streamStub.calledOnce); 119 | }); 120 | }); 121 | 122 | describe('#first', () => { 123 | it('should reject the promise if there is no attached connection object', () => { 124 | const query = new Query(); 125 | return expect(query.first()).to.be.rejectedWith(Error, 'no connection object available'); 126 | }); 127 | 128 | it('should run the query on its connection and return the first result', () => { 129 | const connection = makeConnection(); 130 | const firstRecord = { number: 1 }; 131 | const runStub = stub(connection, 'run') 132 | .returns(Promise.resolve([firstRecord, { number: 2 }, { number: 3 }])); 133 | 134 | const query = new Query(connection).raw('Query'); 135 | return expect(query.first()).to.be.fulfilled.then((result: any) => { 136 | expect(runStub.calledOnce); 137 | expect(result).to.equal(firstRecord); 138 | }); 139 | }); 140 | 141 | it('should return undefined if the query returns no results', () => { 142 | const connection = makeConnection(); 143 | const runStub = stub(connection, 'run').returns(Promise.resolve([])); 144 | 145 | const query = new Query(connection).raw('Query'); 146 | return expect(query.first()).to.be.fulfilled.then((result: any) => { 147 | expect(runStub.calledOnce); 148 | expect(result).to.equal(undefined); 149 | }); 150 | }); 151 | }); 152 | 153 | function makeConnection(): Connection { 154 | const url = 'bolt://localhost'; 155 | const credentials = { username: 'neo4j', password: 'admin1234' }; 156 | return new Connection(url, credentials); 157 | } 158 | }); 159 | -------------------------------------------------------------------------------- /src/query.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs'; 2 | import { Dictionary } from 'lodash'; 3 | import { Connection, Observer } from './connection'; 4 | import { Builder } from './builder'; 5 | import { ClauseCollection } from './clause-collection'; 6 | import { Clause, QueryObject } from './clause'; 7 | 8 | export class Query extends Builder { 9 | protected clauses = new ClauseCollection(); 10 | 11 | /** 12 | * Creates a new query with a given connection. 13 | * 14 | * @param {Connection} connection 15 | */ 16 | constructor(protected connection: Connection | null = null) { 17 | super(); 18 | } 19 | 20 | protected continueChainClause(clause: Clause) { 21 | return this.addClause(clause); 22 | } 23 | 24 | /** 25 | * Runs this query on its connection. If this query was created by calling a 26 | * chainable method of a connection, then its connection was automatically 27 | * set. 28 | * 29 | * Returns a promise that resolves to an array of records. Each key of the 30 | * record is the name of a variable that you specified in your `RETURN` 31 | * clause. 32 | * Eg: 33 | * ```typescript 34 | * connection.match([ 35 | * node('steve', { name: 'Steve' }), 36 | * relation('out', [ 'FriendsWith' ]), 37 | * node('friends'), 38 | * ]) 39 | * .return([ 'steve', 'friends' ]) 40 | * .run(); 41 | * ``` 42 | * 43 | * Would result in the value: 44 | * ``` 45 | * [ 46 | * { 47 | * steve: { ... } // steve node, 48 | * friends: { ... } // first friend, 49 | * }, 50 | * { 51 | * steve: { ... } // steve node, 52 | * friends: { ... } // second friend, 53 | * }, 54 | * { 55 | * steve: { ... } // steve node, 56 | * friends: { ... } // third friend, 57 | * }, 58 | * ] 59 | * ``` 60 | * 61 | * Notice how the steve record is returned for each row, this is how cypher 62 | * works. If you use lodash you can extract all of Steve's friends from the 63 | * results like using `_.map(results, 'friends')`. If you don't, you can use 64 | * ES2015/ES6: `results.map(record => record.friends)`. 65 | * 66 | * If you use typescript you can use the type parameter to hint at the type of 67 | * the return value which is `Dictionary[]`. 68 | * 69 | * Throws an exception if this query does not have a connection or has no 70 | * clauses. 71 | * 72 | * @returns {Promise[]>} 73 | */ 74 | async run(): Promise[]> { 75 | if (!this.connection) { 76 | throw new Error('Cannot run query; no connection object available.'); 77 | } 78 | 79 | return this.connection.run(this); 80 | } 81 | 82 | /** 83 | * Runs this query on its connection. If this query was created by calling a 84 | * chainable method of a connection, then its connection was automatically 85 | * set. 86 | * 87 | * Returns an RxJS observable that emits each record as it is received from the 88 | * database. This is the most efficient way of working with very large 89 | * datasets. Each record is an object where each key is the name of a variable 90 | * that you specified in your return clause. 91 | * 92 | * Eg: 93 | * ```typescript 94 | * const result$ = connection.match([ 95 | * node('steve', { name: 'Steve' }), 96 | * relation('out', [ 'FriendsWith' ]), 97 | * node('friends'), 98 | * ]) 99 | * .return([ 'steve', 'friends' ]) 100 | * .stream(); 101 | * 102 | * // Emits 103 | * // { 104 | * // steve: { ... } // steve node, 105 | * // friends: { ... } // first friend, 106 | * // }, 107 | * // Then emits 108 | * // { 109 | * // steve: { ... } // steve node, 110 | * // friends: { ... } // first friend, 111 | * // }, 112 | * // And so on 113 | * ``` 114 | * 115 | * Notice how the steve record is returned for each row, this is how cypher 116 | * works. You can extract all of steve's friends from the query by using RxJS 117 | * operators: 118 | * ``` 119 | * const friends$ = results$.map(row => row.friends); 120 | * ``` 121 | * 122 | * If you use typescript you can use the type parameter to hint at the type of 123 | * the return value which is `Observable>`. 124 | * 125 | * Throws an exception if this query does not have a connection or has no 126 | * clauses. 127 | */ 128 | stream(): Observable> { 129 | if (!this.connection) { 130 | return new Observable((subscriber: Observer>): void => { 131 | subscriber.error(new Error('Cannot run query; no connection object available.')); 132 | }); 133 | } 134 | 135 | return this.connection.stream(this); 136 | } 137 | 138 | /** 139 | * Runs the current query on its connection and returns the first result. 140 | * If the query was created by calling a chainable method of a connection, 141 | * the query's connection was automatically set. 142 | * 143 | * If 0 results were returned from the database, returns `undefined`. 144 | * 145 | * Returns a promise that resolves to a single record. Each key of the 146 | * record is the name of a variable that you specified in your `RETURN` 147 | * clause. 148 | * 149 | * If you use typescript you can use the type parameter to hint at the type of 150 | * the return value which is `Dictionary`. Note that this function returns 151 | * `undefined` if the result set was empty. 152 | */ 153 | first(): Promise | undefined> { 154 | return this.run().then(results => results && results.length > 0 ? results[0] : undefined); 155 | } 156 | 157 | // Clause proxied methods 158 | 159 | /** 160 | * Returns the query as a string with parameter variables. 161 | * 162 | * Eg: 163 | * ```typescript 164 | * connection.match([ 165 | * node('steve', { name: 'Steve' }), 166 | * relation('out', [ 'FriendsWith' ]), 167 | * node('friends'), 168 | * ]) 169 | * .return([ 'steve', 'friends' ]) 170 | * .build(); 171 | * 172 | * // MATCH (steve { name: $name })-[:FriendsWith]->(friends) 173 | * // RETURN steve, friends 174 | * ``` 175 | * 176 | * @returns {string} 177 | */ 178 | build(): string { 179 | return this.clauses.build(); 180 | } 181 | 182 | /** 183 | * Synonym for `build()`. 184 | * @returns {string} 185 | */ 186 | toString(): string { 187 | return this.clauses.toString(); 188 | } 189 | 190 | /** 191 | * Returns an object that includes both the query and the params ready to be 192 | * passed to the neo4j driver. 193 | */ 194 | buildQueryObject(): QueryObject { 195 | return this.clauses.buildQueryObject(); 196 | } 197 | 198 | /** 199 | * Like `build`, but will insert the values of the parameters into the string 200 | * so queries are easier to debug. __Note: this should only ever be used for 201 | * debugging__. 202 | * 203 | * ```typescript 204 | * connection.match([ 205 | * node('steve', { name: 'Steve' }), 206 | * relation('out', [ 'FriendsWith' ]), 207 | * node('friends'), 208 | * ]) 209 | * .return([ 'steve', 'friends' ]) 210 | * .build(); 211 | * 212 | * // MATCH (steve { name: 'Steve' })-[:FriendsWith]->(friends) 213 | * // RETURN steve, friends 214 | * ``` 215 | * 216 | * @returns {string} 217 | */ 218 | interpolate(): string { 219 | return this.clauses.interpolate(); 220 | } 221 | 222 | /** 223 | * Returns an array of all the clauses in this query. 224 | * @returns {Clause[]} 225 | */ 226 | getClauses(): Clause[] { 227 | return this.clauses.getClauses(); 228 | } 229 | 230 | /** 231 | * Adds a new clause to the query. You probably won't ever need to call this 232 | * directly, but there is nothing stopping you. 233 | * 234 | * @param {Clause} clause 235 | * @returns {this} 236 | */ 237 | addClause(clause: Clause): this { 238 | this.clauses.addClause(clause); 239 | return this; 240 | } 241 | } 242 | -------------------------------------------------------------------------------- /src/transformer.ts: -------------------------------------------------------------------------------- 1 | import { Dictionary, map, mapValues, isArray } from 'lodash'; 2 | import * as neo4j from 'neo4j-driver'; 3 | import { Record, Integer } from 'neo4j-driver/types'; 4 | 5 | export type NeoValue = string | boolean | null | number | Integer; 6 | export interface NeoNode { 7 | identity: Integer; 8 | labels: string[]; 9 | properties: Dictionary; 10 | } 11 | export interface NeoRelation { 12 | identity: Integer; 13 | start: Integer; 14 | end: Integer; 15 | type: string; 16 | properties: Dictionary; 17 | } 18 | 19 | export type PlainValue = string | boolean | null | number; 20 | export type PlainArray = string[] | boolean[] | number[]; 21 | export interface Node

> { 22 | identity: string; 23 | labels: string[]; 24 | properties: P; 25 | } 26 | export interface Relation

> { 27 | identity: string; 28 | start: string; 29 | end: string; 30 | label: string; 31 | properties: P; 32 | } 33 | 34 | export class Transformer { 35 | transformRecords(records: Record[]): Dictionary[] { 36 | return map(records, rec => this.transformRecord(rec)); 37 | } 38 | 39 | transformRecord(record: Record): Dictionary { 40 | return mapValues(record.toObject() as any, node => this.transformValue(node)); 41 | } 42 | 43 | private transformValue(value: any): any { 44 | if (this.isPlainValue(value)) { 45 | return value; 46 | } 47 | if (isArray(value)) { 48 | return map(value, v => this.transformValue(v)); 49 | } 50 | if (neo4j.isInt(value)) { 51 | return this.convertInteger(value); 52 | } 53 | if (this.isNode(value)) { 54 | return this.transformNode(value); 55 | } 56 | if (this.isRelation(value)) { 57 | return this.transformRelation(value); 58 | } 59 | if (typeof value === 'object') { 60 | return mapValues(value, v => this.transformValue(v)); 61 | } 62 | return null; 63 | } 64 | 65 | private isPlainValue(value: any): value is PlainValue { 66 | const type = typeof value; 67 | return value == null || type === 'string' || type === 'boolean' || type === 'number'; 68 | } 69 | 70 | private isNode(node: any): node is NeoNode { 71 | return node !== null 72 | && typeof node === 'object' 73 | && !isArray(node) 74 | && node.identity 75 | && node.labels 76 | && node.properties; 77 | } 78 | 79 | private transformNode(node: NeoNode): Node { 80 | return { 81 | identity: neo4j.integer.toString(node.identity), 82 | labels: node.labels, 83 | properties: mapValues(node.properties, this.transformValue.bind(this)), 84 | }; 85 | } 86 | 87 | private isRelation(rel: Dictionary): rel is NeoRelation { 88 | return rel.identity && rel.type && rel.properties && rel.start && rel.end; 89 | } 90 | 91 | private transformRelation(rel: NeoRelation): Relation { 92 | return { 93 | identity: neo4j.integer.toString(rel.identity), 94 | start: neo4j.integer.toString(rel.start), 95 | end: neo4j.integer.toString(rel.end), 96 | label: rel.type, 97 | properties: mapValues(rel.properties, this.transformValue.bind(this)), 98 | }; 99 | } 100 | 101 | private convertInteger(num: Integer) { 102 | if (neo4j.integer.inSafeRange(num)) { 103 | return neo4j.integer.toNumber(num); 104 | } 105 | return neo4j.integer.toString(num); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/utils.spec.ts: -------------------------------------------------------------------------------- 1 | import { uniqueString } from './utils'; 2 | import { expect } from '../test-setup'; 3 | 4 | describe('#uniqueString', () => { 5 | it('should return the given string unchanged if existing is an empty array', () => { 6 | const str = uniqueString('aUniqueString', []); 7 | expect(str).to.equal('aUniqueString'); 8 | }); 9 | 10 | it('should convert a string to camel case', () => { 11 | const str = uniqueString('a variable name', []); 12 | expect(str).to.equal('aVariableName'); 13 | }); 14 | 15 | it('should append a unique number to the end of the input', () => { 16 | const str = uniqueString('aString', ['aString1', 'aString2']); 17 | expect(str).to.equal('aString3'); 18 | }); 19 | 20 | it('should convert to camel case before calculating unique suffix', () => { 21 | const str = uniqueString('a variable name', ['aVariableName1']); 22 | expect(str).to.equal('aVariableName2'); 23 | }); 24 | 25 | it('should count an existing string with no number suffix as the number 1', () => { 26 | const str = uniqueString('aString', ['aString']); 27 | expect(str).to.equal('aString2'); 28 | }); 29 | 30 | it('should only consider exactly matching strings', () => { 31 | const str = uniqueString('aString', ['aLongString', 'aStringTwo']); 32 | expect(str).to.equal('aString'); 33 | }); 34 | 35 | it('should ignore duplicate existing names', () => { 36 | const str = uniqueString('aString', ['aString', 'aString1', 'aString1']); 37 | expect(str).to.equal('aString2'); 38 | }); 39 | 40 | it('should attempt to use trailing numbers on the given string', () => { 41 | let str = uniqueString('aString456', ['aString5', 'aString6']); 42 | expect(str).to.equal('aString456'); 43 | 44 | str = uniqueString('aString2', ['aString5', 'aString6']); 45 | expect(str).to.equal('aString2'); 46 | }); 47 | 48 | it('should alter the suffix of the given string if it is already taken', () => { 49 | const str = uniqueString('aString7', ['aString6', 'aString7', 'aString8']); 50 | expect(str).to.equal('aString9'); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | camelCase, 3 | castArray, 4 | isArray, 5 | isBoolean, 6 | isNil, 7 | isNumber, 8 | isObject, 9 | isString, 10 | map, 11 | reduce, 12 | Many, 13 | } from 'lodash'; 14 | 15 | /** 16 | * Converts a string to camel case and ensures it is unique in the provided 17 | * list. 18 | * @param {string} str 19 | * @param {Array} existing 20 | * @return {string} 21 | */ 22 | export function uniqueString(str: string, existing: string[]) { 23 | let camelString = camelCase(str); 24 | 25 | // Check if the string already has a number extension 26 | let number = null; 27 | const matches = camelString.match(/[0-9]+$/); 28 | if (matches) { 29 | number = +matches[0]; 30 | camelString = camelString.substr(0, camelString.length - matches[0].length); 31 | } 32 | 33 | // Compute all taken suffixes that are similar to the given string 34 | const regex = new RegExp(`^${camelString}([0-9]*)$`); 35 | const takenSuffixes = reduce( 36 | existing, 37 | (suffixes, existingString) => { 38 | const matches = existingString.match(regex); 39 | if (matches) { 40 | const [, suffix] = matches; 41 | suffixes.push(suffix ? +suffix : 1); 42 | } 43 | return suffixes; 44 | }, 45 | [] as number[], 46 | ); 47 | 48 | // If there was no suffix on the given string or it was already taken, 49 | // compute the new suffix. 50 | if (!number || takenSuffixes.indexOf(number) !== -1) { 51 | number = Math.max(0, ...takenSuffixes) + 1; 52 | } 53 | 54 | // Append the suffix if it is not 1 55 | return camelString + (number === 1 ? '' : number); 56 | } 57 | 58 | /** 59 | * Converts a Javascript value into a string suitable for a cypher query. 60 | * @param {object|Array|string|boolean|number} value 61 | * @return {string} 62 | */ 63 | export function stringifyValue(value: any): string { 64 | if (isNumber(value) || isBoolean(value)) { 65 | return `${value}`; 66 | } 67 | if (isString(value)) { 68 | return `'${value}'`; 69 | } 70 | if (isArray(value)) { 71 | const str = map(value, stringifyValue).join(', '); 72 | return `[ ${str} ]`; 73 | } 74 | if (isObject(value)) { 75 | const pairs = map(value, (el, key) => `${key}: ${stringifyValue(el)}`); 76 | const str = pairs.join(', '); 77 | return `{ ${str} }`; 78 | } 79 | return ''; 80 | } 81 | 82 | /** 83 | * Converts labels into a string that can be put into a pattern. 84 | * 85 | * @param {string|array} labels 86 | * @param relation When true, joins labels by a | instead of : 87 | * @return {string} 88 | */ 89 | export function stringifyLabels(labels: Many, relation = false) { 90 | if (labels.length === 0) { 91 | return ''; 92 | } 93 | return `:${castArray(labels).join(relation ? '|' : ':')}`; 94 | } 95 | 96 | export type PathLength = '*' 97 | | number 98 | | [number | null | undefined] 99 | | [number | null | undefined, number | null | undefined]; 100 | 101 | /** 102 | * Converts a path length bounds into a string to put into a relationship. 103 | * @param {Array|int} bounds An array of bounds 104 | * @return {string} 105 | */ 106 | export function stringifyPathLength(bounds?: PathLength): string { 107 | if (isNil(bounds)) { 108 | return ''; 109 | } 110 | 111 | if (bounds === '*') { 112 | return '*'; 113 | } 114 | 115 | if (isNumber(bounds)) { 116 | return `*${bounds}`; 117 | } 118 | 119 | const lower = isNil(bounds[0]) ? '' : `${bounds[0]}`; 120 | const upper = isNil(bounds[1]) ? '' : `${bounds[1]}`; 121 | return lower || upper ? `*${lower}..${upper}` : '*'; 122 | } 123 | -------------------------------------------------------------------------------- /test-setup.ts: -------------------------------------------------------------------------------- 1 | const chai = require('chai'); 2 | chai.use(require('chai-as-promised')); 3 | export const expect = chai.expect; 4 | -------------------------------------------------------------------------------- /tests/connection.test.ts: -------------------------------------------------------------------------------- 1 | import { Dictionary, each } from 'lodash'; 2 | import * as neo4j from 'neo4j-driver'; 3 | import { Driver, Session } from 'neo4j-driver/types'; 4 | import { AuthToken, Config } from 'neo4j-driver/types/driver'; 5 | import { Observable } from 'rxjs'; 6 | import { tap } from 'rxjs/operators'; 7 | import { SinonSpy, SinonStub, spy, stub } from 'sinon'; 8 | import { Connection, Node, Query } from '../src'; 9 | import { NodePattern } from '../src/clauses'; 10 | import { expect } from '../test-setup'; 11 | import { neo4jCredentials, neo4jUrl, waitForNeo } from './utils'; 12 | 13 | type ArgumentTypes any> 14 | = T extends (...a: infer Args) => any ? Args : never; 15 | type SinonSpyFor any> 16 | = SinonSpy, ReturnType>; 17 | type SinonStubFor any> 18 | = SinonStub, ReturnType>; 19 | 20 | describe('Connection', () => { 21 | let connection: Connection; 22 | let driver: Driver; 23 | let driverCloseSpy: SinonSpyFor; 24 | let driverSessionStub: SinonStubFor; 25 | let sessionRunSpy: SinonSpyFor; 26 | let sessionCloseSpy: SinonSpyFor; 27 | const stubSession = stub<[Session], void>(); 28 | 29 | function attachSessionSpies(session: Session): void { 30 | sessionRunSpy = spy(session, 'run'); 31 | sessionCloseSpy = spy(session, 'close'); 32 | } 33 | 34 | function makeSessionMock(createSession: Driver['session']): Driver['session'] { 35 | return (...args) => { 36 | const session = createSession(...args); 37 | stubSession(session); 38 | return session; 39 | }; 40 | } 41 | 42 | function driverConstructor(url: string, authToken?: AuthToken, config?: Config) { 43 | driver = neo4j.driver(url, authToken, config); 44 | const mock = makeSessionMock(driver.session.bind(driver)); 45 | driverSessionStub = stub(driver, 'session').callsFake(mock); 46 | driverCloseSpy = spy(driver, 'close'); 47 | return driver; 48 | } 49 | 50 | // Wait for neo4j to be ready before testing 51 | before(waitForNeo); 52 | 53 | beforeEach(() => { 54 | stubSession.callsFake(attachSessionSpies); 55 | connection = new Connection(neo4jUrl, neo4jCredentials, driverConstructor); 56 | }); 57 | 58 | afterEach(() => connection.close()); 59 | 60 | describe('#constructor', () => { 61 | it('should default to neo4j driver', async () => { 62 | const driverSpy = spy(neo4j, 'driver'); 63 | const connection = new Connection(neo4jUrl, neo4jCredentials); 64 | 65 | expect(driverSpy.calledOnce).to.equal(true); 66 | 67 | await connection.close(); 68 | driverSpy.restore(); 69 | }); 70 | 71 | it('should accept a custom driver constructor function', async () => { 72 | const constructorSpy = spy(driverConstructor); 73 | const connection = new Connection(neo4jUrl, neo4jCredentials, constructorSpy); 74 | expect(constructorSpy.calledOnce).to.equal(true); 75 | expect(constructorSpy.firstCall.args[0]).to.equal(neo4jUrl); 76 | await connection.close(); 77 | }); 78 | 79 | it('should pass driver options to the driver constructor', async () => { 80 | const constructorSpy = spy(driverConstructor); 81 | const driverConfig = { maxConnectionPoolSize: 5 }; 82 | const connection = new Connection(neo4jUrl, neo4jCredentials, { 83 | driverConfig, 84 | driverConstructor: constructorSpy, 85 | }); 86 | expect(constructorSpy.calledOnce).to.equal(true); 87 | expect(constructorSpy.firstCall.args[0]).to.equal(neo4jUrl); 88 | expect(constructorSpy.firstCall.args[2]).to.deep.equal(driverConfig); 89 | await connection.close(); 90 | }); 91 | }); 92 | 93 | describe('#close', () => { 94 | it('should close the driver', async () => { 95 | await connection.close(); 96 | expect(driverCloseSpy.calledOnce).to.equal(true); 97 | }); 98 | 99 | it('should only close the driver once', async () => { 100 | await connection.close(); 101 | await connection.close(); 102 | expect(driverCloseSpy.calledOnce).to.equal(true); 103 | }); 104 | }); 105 | 106 | describe('#session', () => { 107 | it('should use the driver to create a session', () => { 108 | connection.session(); 109 | expect(driverSessionStub.calledOnce).to.equal(true); 110 | }); 111 | 112 | it('should return null if the connection has been closed', async () => { 113 | await connection.close(); 114 | const result = connection.session(); 115 | 116 | expect(driverSessionStub.notCalled).to.equal(true); 117 | expect(result).to.equal(null); 118 | }); 119 | }); 120 | 121 | describe('#run', () => { 122 | it('should reject if there are no clauses in the query', async () => { 123 | const promise = connection.run(connection.query()); 124 | await expect(promise).to.be.rejectedWith(Error, 'no clauses'); 125 | }); 126 | 127 | it('should reject if the connection has been closed', async () => { 128 | await connection.close(); 129 | const promise = connection.run(connection.query().return('1')); 130 | await expect(promise).to.be.rejectedWith(Error, 'connection is not open'); 131 | }); 132 | 133 | it('should reject if a session cannot be opened', async () => { 134 | const connectionSessionStub = stub(connection, 'session').returns(null); 135 | const promise = connection.run(connection.query().return('1')); 136 | await expect(promise).to.be.rejectedWith(Error, 'connection is not open'); 137 | connectionSessionStub.restore(); 138 | }); 139 | 140 | it('should run the query through a session', async () => { 141 | const params = {}; 142 | const query = new Query().raw('RETURN 1', params); 143 | 144 | const promise = connection.run(query); 145 | await expect(promise).to.be.fulfilled.then(() => { 146 | expect(sessionRunSpy.calledOnce).to.equal(true); 147 | expect(sessionRunSpy.calledWith('RETURN 1', params)); 148 | }); 149 | }); 150 | 151 | it('should close the session after running a query', async () => { 152 | const promise = connection.run((new Query()).raw('RETURN 1')); 153 | await expect(promise).to.be.fulfilled 154 | .then(() => expect(sessionCloseSpy.calledOnce).to.equal(true)); 155 | }); 156 | 157 | it('should close the session when run() throws', async () => { 158 | const promise = connection.run((new Query()).raw('RETURN a')); 159 | await expect(promise).to.be.rejectedWith(Error) 160 | .then(() => expect(sessionCloseSpy.calledOnce)); 161 | }); 162 | 163 | describe('when session.close throws', async () => { 164 | const message = 'Fake error'; 165 | let sessionCloseStub: SinonStubFor; 166 | 167 | beforeEach(() => { 168 | stubSession.resetBehavior(); 169 | stubSession.callsFake((session) => { 170 | sessionCloseStub = stub(session, 'close').throws(new Error(message)); 171 | }); 172 | }); 173 | 174 | it('the error should bubble up', async () => { 175 | const promise = connection.run(new Query().raw('RETURN 1')); 176 | await expect(promise).to.be.rejectedWith(Error, message); 177 | }); 178 | 179 | it('does not call session.close again', async () => { 180 | try { 181 | await connection.run(new Query().raw('RETURN a')); 182 | } catch (e) {} 183 | expect(sessionCloseStub.calledOnce).to.equal(true); 184 | }); 185 | }); 186 | }); 187 | 188 | describe('stream', () => { 189 | const params = {}; 190 | const query = new Query().matchNode('n', 'TestStreamRecord').return('n'); 191 | const records = [ 192 | { number: 1 }, 193 | { number: 2 }, 194 | { number: 3 }, 195 | ]; 196 | 197 | before('setup session run return value', async () => { 198 | const connection = new Connection(neo4jUrl, neo4jCredentials); 199 | await connection 200 | .unwind(records, 'map') 201 | .createNode('n', 'TestStreamRecord') 202 | .setVariables({ n: 'map' }) 203 | .run(); 204 | await connection.close(); 205 | }); 206 | 207 | after('clear the database', async () => { 208 | const connection = new Connection(neo4jUrl, neo4jCredentials); 209 | await connection 210 | .matchNode('n', 'TestStreamRecord') 211 | .delete('n') 212 | .run(); 213 | await connection.close(); 214 | }); 215 | 216 | it('should return errored observable if there are no clauses in the query', () => { 217 | const observable = connection.stream(connection.query()); 218 | expect(observable).to.be.an.instanceOf(Observable); 219 | 220 | observable.subscribe({ 221 | next: () => expect.fail(null, null, 'Observable should not emit anything'), 222 | error(error) { 223 | expect(error).to.be.instanceOf(Error); 224 | expect(error.message).to.include('no clauses'); 225 | }, 226 | complete: () => expect.fail(null, null, 'Observable should not complete successfully'), 227 | }); 228 | }); 229 | 230 | it('should return errored observable if the connection has been closed', async () => { 231 | await connection.close(); 232 | const observable = connection.stream(new Query().return('1')); 233 | expect(observable).to.be.an.instanceOf(Observable); 234 | 235 | observable.subscribe({ 236 | next: () => expect.fail(null, null, 'Observable should not emit anything'), 237 | error(error) { 238 | expect(error).to.be.instanceOf(Error); 239 | expect(error.message).to.include('connection is not open'); 240 | }, 241 | complete: () => expect.fail(null, null, 'Observable should not complete successfully'), 242 | }); 243 | }); 244 | 245 | it('should run the query through a session', () => { 246 | const observable = connection.stream(query); 247 | expect(observable).to.be.an.instanceOf(Observable); 248 | 249 | let count = 0; 250 | return observable.pipe( 251 | tap((row) => { 252 | expect(row.n.properties).to.deep.equal(records[count]); 253 | expect(row.n.labels).to.deep.equal(['TestStreamRecord']); 254 | count += 1; 255 | }), 256 | ) 257 | .toPromise() 258 | .then(() => { 259 | expect(count).to.equal(records.length); 260 | expect(sessionRunSpy.calledOnce).to.equal(true); 261 | expect(sessionRunSpy.calledWith(query.build(), params)); 262 | }); 263 | }); 264 | 265 | it('should close the session after running a query', () => { 266 | const observable = connection.stream(query); 267 | 268 | expect(observable).to.be.an.instanceOf(Observable); 269 | return observable.toPromise().then(() => expect(sessionCloseSpy.calledOnce)); 270 | }); 271 | 272 | it('should close the session when run() throws', (done) => { 273 | const query = connection.query().return('a'); 274 | const observable = connection.stream(query); 275 | 276 | expect(observable).to.be.an.instanceOf(Observable); 277 | observable.subscribe({ 278 | next: () => expect.fail(null, null, 'Observable should not emit any items'), 279 | error() { 280 | expect(sessionCloseSpy.calledOnce).to.equal(true); 281 | done(); 282 | }, 283 | complete: () => expect.fail(null, null, 'Observable should not complete without an error'), 284 | }); 285 | }); 286 | }); 287 | 288 | describe('query methods', () => { 289 | const methods: Dictionary = { 290 | create: () => connection.create(new NodePattern('Node')), 291 | createNode: () => connection.createNode('Node'), 292 | createUnique: () => connection.createUnique(new NodePattern('Node')), 293 | createUniqueNode: () => connection.createUniqueNode('Node'), 294 | delete: () => connection.delete('node'), 295 | detachDelete: () => connection.detachDelete('node'), 296 | limit: () => connection.limit(1), 297 | match: () => connection.match(new NodePattern('Node')), 298 | matchNode: () => connection.matchNode('Node'), 299 | merge: () => connection.merge(new NodePattern('Node')), 300 | onCreateSet: () => connection.onCreate.set({}, { merge: false }), 301 | onCreateSetLabels: () => connection.onCreate.setLabels({}), 302 | onCreateSetValues: () => connection.onCreate.setValues({}), 303 | onCreateSetVariables: () => connection.onCreate.setVariables({}, false), 304 | onMatchSet: () => connection.onMatch.set({}, { merge: false }), 305 | onMatchSetLabels: () => connection.onMatch.setLabels({}), 306 | onMatchSetValues: () => connection.onMatch.setValues({}), 307 | onMatchSetVariables: () => connection.onMatch.setVariables({}, false), 308 | optionalMatch: () => connection.optionalMatch(new NodePattern('Node')), 309 | orderBy: () => connection.orderBy('name'), 310 | query: () => connection.query(), 311 | raw: () => connection.raw('name'), 312 | remove: () => connection.remove({ properties: { node: ['prop1', 'prop2'] } }), 313 | removeProperties: () => connection.removeProperties({ node: ['prop1', 'prop2'] }), 314 | removeLabels: () => connection.removeLabels({ node: 'label' }), 315 | return: () => connection.return('node'), 316 | returnDistinct: () => connection.returnDistinct('node'), 317 | set: () => connection.set({}, { merge: false }), 318 | setLabels: () => connection.setLabels({}), 319 | setValues: () => connection.setValues({}), 320 | setVariables: () => connection.setVariables({}, false), 321 | skip: () => connection.skip(1), 322 | unwind: () => connection.unwind([1, 2, 3], 'number'), 323 | where: () => connection.where([]), 324 | with: () => connection.with('node'), 325 | }; 326 | 327 | each(methods, (fn, name) => { 328 | it(`${name} should return a query object`, () => { 329 | expect(fn()).to.be.an.instanceof(Query); 330 | }); 331 | }); 332 | }); 333 | }); 334 | -------------------------------------------------------------------------------- /tests/scenarios.test.ts: -------------------------------------------------------------------------------- 1 | import { Dictionary, isNil } from 'lodash'; 2 | import { Connection } from '../src'; 3 | import { node, relation } from '../src/clauses'; 4 | import { expect } from '../test-setup'; 5 | import { neo4jCredentials, neo4jUrl, waitForNeo } from './utils'; 6 | 7 | function expectResults( 8 | results: any[], 9 | length?: number | null, 10 | properties?: string[] | null, 11 | cb?: null | ((row: any) => any), 12 | ) { 13 | expect(results).to.be.an.instanceOf(Array); 14 | 15 | if (!isNil(length)) { 16 | expect(results).to.have.lengthOf(length); 17 | } 18 | 19 | results.forEach((row) => { 20 | expect(row).to.be.an.instanceOf(Object); 21 | 22 | if (!isNil(properties)) { 23 | expect(row).to.have.own.keys(properties); 24 | } 25 | 26 | if (!isNil(cb)) { 27 | cb(row); 28 | } 29 | }); 30 | } 31 | 32 | function expectNode(record: any, labels?: string[], properties?: Dictionary) { 33 | expect(record).to.be.an.instanceOf(Object) 34 | .and.to.have.keys(['identity', 'properties', 'labels']); 35 | 36 | expect(record.identity).to.be.a('string') 37 | .and.to.match(/[0-9]+/); 38 | 39 | expect(record.labels).to.be.an.instanceOf(Array); 40 | record.labels.forEach((label: string) => expect(label).to.be.a('string')); 41 | if (labels) { 42 | expect(record.labels).to.have.members(labels); 43 | } 44 | 45 | expect(record.properties).to.be.an('object'); 46 | if (properties) { 47 | expect(record.properties).to.eql(properties); 48 | } 49 | } 50 | 51 | function expectRelation(relation: any, label?: string, properties?: Dictionary) { 52 | expect(relation).to.be.an.instanceOf(Object) 53 | .and.to.have.keys(['identity', 'properties', 'label', 'start', 'end']); 54 | 55 | expect(relation.identity).to.be.a('string') 56 | .and.to.match(/[0-9]+/); 57 | expect(relation.start).to.be.a('string') 58 | .and.to.match(/[0-9]+/); 59 | expect(relation.end).to.be.a('string') 60 | .and.to.match(/[0-9]+/); 61 | 62 | expect(relation.label).to.be.a('string'); 63 | if (label) { 64 | expect(relation.label).to.equal(label); 65 | } 66 | 67 | expect(relation.properties).to.be.an('object'); 68 | if (properties) { 69 | expect(relation.properties).to.eql(properties); 70 | } 71 | } 72 | 73 | describe('scenarios', () => { 74 | let db: Connection; 75 | 76 | before(waitForNeo); 77 | before(() => db = new Connection(neo4jUrl, neo4jCredentials)); 78 | before(() => db.matchNode('node').detachDelete('node').run()); 79 | after(() => db.matchNode('node').detachDelete('node').run()); 80 | after(() => db.close()); 81 | 82 | describe('node', () => { 83 | it('should create a node', async () => { 84 | const results = await db.createNode('person', 'Person', { name: 'Alan', age: 45 }) 85 | .return('person') 86 | .run(); 87 | 88 | expectResults(results, 1, ['person'], (row) => { 89 | expectNode(row.person, ['Person'], { name: 'Alan', age: 45 }); 90 | }); 91 | }); 92 | 93 | it('should create a node without returning anything', async () => { 94 | const results = await db.createNode('person', 'Person', { name: 'Steve', age: 42 }) 95 | .run(); 96 | 97 | expectResults(results, 0); 98 | }); 99 | 100 | it('should fetch multiple nodes', async () => { 101 | const results = await db.matchNode('person', 'Person') 102 | .return('person') 103 | .run(); 104 | 105 | expectResults(results, 2, ['person'], (row) => { 106 | expectNode(row.person, ['Person']); 107 | expect(row.person.properties).to.have.keys(['name', 'age']); 108 | }); 109 | }); 110 | 111 | it('should fetch a single node using limit', async () => { 112 | const results = await db.matchNode('person', 'Person') 113 | .return('person') 114 | .skip(1) 115 | .limit(1) 116 | .run(); 117 | 118 | expectResults(results, 1, ['person']); 119 | }); 120 | 121 | it('should fetch a property of a set of nodes', async () => { 122 | const results = await db.matchNode('person', 'Person') 123 | .return({ 'person.age': 'yearsOld' }) 124 | .run(); 125 | 126 | expectResults(results, 2, ['yearsOld'], row => expect(row.yearsOld).to.be.an('number')); 127 | }); 128 | 129 | it('should return an array property', async () => { 130 | const results = await db.createNode('arrNode', 'ArrNode', { 131 | values: [1, 2, 3], 132 | }) 133 | .return('arrNode') 134 | .run(); 135 | 136 | expectResults(results, 1, ['arrNode'], (row) => { 137 | expectNode(row.arrNode, ['ArrNode'], { 138 | values: [1, 2, 3], 139 | }); 140 | }); 141 | }); 142 | 143 | it('should return a relationship', async () => { 144 | const results = await db.create([ 145 | node('person', 'Person', { name: 'Alfred', age: 64 }), 146 | relation('out', 'hasJob', 'HasJob', { since: 2004 }), 147 | node('job', 'Job', { name: 'Butler' }), 148 | ]) 149 | .return(['person', 'hasJob', 'job']) 150 | .run(); 151 | 152 | expectResults(results, 1, ['person', 'hasJob', 'job'], (row) => { 153 | expectNode(row.person, ['Person'], { name: 'Alfred', age: 64 }); 154 | expectRelation(row.hasJob, 'HasJob', { since: 2004 }); 155 | expectNode(row.job, ['Job'], { name: 'Butler' }); 156 | }); 157 | }); 158 | 159 | it('should handle an array of nodes and relationships', async () => { 160 | // Create relationships 161 | await db.create([ 162 | node(['City'], { name: 'Cityburg' }), 163 | relation('out', ['Road'], { length: 10 }), 164 | node(['City'], { name: 'Townsville' }), 165 | relation('out', ['Road'], { length: 5 }), 166 | node(['City'], { name: 'Rural hideout' }), 167 | relation('out', ['Road'], { length: 14 }), 168 | node(['City'], { name: 'Village' }), 169 | ]) 170 | .run(); 171 | 172 | const results = await db.raw('MATCH p = (:City)-[:Road*3]->(:City)') 173 | .return({ 'relationships(p)': 'rels', 'nodes(p)': 'nodes' }) 174 | .run(); 175 | 176 | expectResults(results, 1, ['rels', 'nodes'], (row) => { 177 | expect(row.rels).to.be.an.instanceOf(Array) 178 | .and.to.have.a.lengthOf(3); 179 | row.rels.forEach((rel: any) => { 180 | expectRelation(rel, 'Road'); 181 | expect(rel.properties).to.have.own.keys(['length']); 182 | }); 183 | 184 | expect(row.nodes).to.be.an.instanceOf(Array) 185 | .and.to.have.a.lengthOf(4); 186 | row.nodes.forEach((node: any) => { 187 | expectNode(node, ['City']); 188 | expect(node.properties).to.have.own.keys(['name']); 189 | }); 190 | }); 191 | }); 192 | }); 193 | 194 | describe('literals', () => { 195 | it('should handle value literals', async () => { 196 | const results = await db.return([ 197 | '1 AS numberVal', 198 | '"string" AS stringVal', 199 | 'null AS nullVal', 200 | 'true AS boolVal', 201 | ]) 202 | .run(); 203 | 204 | expectResults(results, 1, null, (row) => { 205 | expect(row).to.have.own.property('numberVal', 1); 206 | expect(row).to.have.own.property('stringVal', 'string'); 207 | expect(row).to.have.own.property('nullVal', null); 208 | expect(row).to.have.own.property('boolVal', true); 209 | }); 210 | }); 211 | 212 | it('should handle an array literal', async () => { 213 | const results = await db.return('range(0, 5)').run(); 214 | 215 | expectResults(results, 1, ['range(0, 5)'], (row) => { 216 | expect(row['range(0, 5)']).to.eql([0, 1, 2, 3, 4, 5]); 217 | }); 218 | }); 219 | 220 | it('should handle a map literal', async () => { 221 | const results = await db.return('{ a: 1, b: true, c: "a string" } as map').run(); 222 | 223 | expectResults(results, 1, ['map'], (row) => { 224 | expect(row.map).to.eql({ a: 1, b: true, c: 'a string' }); 225 | }); 226 | }); 227 | 228 | it('should handle a nested array literal', async () => { 229 | const results = await db.return('{ a: [1, 2, 3], b: [4, 5, 6] } as map').run(); 230 | 231 | expectResults(results, 1, ['map'], (row) => { 232 | expect(row.map).to.eql({ a: [1, 2, 3], b: [4, 5, 6] }); 233 | }); 234 | }); 235 | 236 | it('should handle a nested map literal', async () => { 237 | const results = await db.return('[{ a: "name", b: true }, { c: 1, d: null }] as arr').run(); 238 | 239 | expectResults(results, 1, ['arr'], (row) => { 240 | expect(row.arr).to.eql([ 241 | { a: 'name', b: true }, 242 | { c: 1, d: null }, 243 | ]); 244 | }); 245 | }); 246 | }); 247 | }); 248 | -------------------------------------------------------------------------------- /tests/utils.ts: -------------------------------------------------------------------------------- 1 | import { Connection, Credentials } from '../src'; 2 | 3 | export const neo4jUrl: string = process.env.NEO4J_URL as string; 4 | export const neo4jCredentials: Credentials = { 5 | username: process.env.NEO4J_USER as string, 6 | password: process.env.NEO4J_PASS as string, 7 | }; 8 | 9 | export async function waitForNeo(this: Mocha.Context) { 10 | if (this && 'timeout' in this) { 11 | this.timeout(40000); 12 | } 13 | 14 | let attempts = 0; 15 | const connection = new Connection(neo4jUrl, neo4jCredentials); 16 | while (attempts < 30) { 17 | // Wait a short time before trying again 18 | if (attempts > 0) await new Promise(res => setTimeout(res, 1000)); 19 | 20 | try { 21 | // Attempt a query and exit the loop if it succeeds 22 | attempts += 1; 23 | await connection.query().return('1').run(); 24 | break; 25 | } catch {} 26 | } 27 | await connection.close(); 28 | } 29 | -------------------------------------------------------------------------------- /tsconfig.declaration.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": [ 4 | "src/**/*", 5 | "typings/**/*" 6 | ], 7 | "exclude": [ 8 | "**/*.spec.ts" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "allowJs": false, 5 | "allowSyntheticDefaultImports": true, 6 | "esModuleInterop": true, 7 | "lib": [ 8 | "es2016", 9 | "dom" 10 | ], 11 | "module": "commonjs", 12 | "moduleResolution": "node", 13 | "outDir": "dist", 14 | "sourceMap": true, 15 | "strict": true, 16 | "target": "es5", 17 | "typeRoots": [ 18 | "node_modules/@types", 19 | "typings" 20 | ] 21 | }, 22 | "exclude": [ 23 | "node_modules", 24 | "dist" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tslint-config-airbnb" 3 | } 4 | -------------------------------------------------------------------------------- /typings/node-cleanup/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'node-cleanup' { 2 | function cleanup(callback: () => void): void; 3 | export = cleanup; 4 | } 5 | --------------------------------------------------------------------------------