diff --git a/.codoopts b/.codoopts deleted file mode 100644 index 3e0d9af..0000000 --- a/.codoopts +++ /dev/null @@ -1,4 +0,0 @@ ---name 'Node-Neo4j API Documentation' ---title 'Node-Neo4j API Documentation' ---readme API.md -./lib-old diff --git a/.gitignore b/.gitignore index 386e0bb..de5f125 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,5 @@ node_modules/ npm-debug.log -/doc/ - /neo4j-* /neo4j diff --git a/.travis.yml b/.travis.yml index 4f84734..4220fc1 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,26 +1,40 @@ language: node_js node_js: - - "0.11" - - "0.10" + - "5" + - "4" + - "io.js" + - "0.12" + - "0.10" jdk: - - oraclejdk7 # needed for local Neo4j 2.0+ + - oraclejdk7 # needed for local Neo4j 2.0+ env: - # test across multiple versions of Neo4j: - - NEO4J_VERSION="2.1.5" - - NEO4J_VERSION="2.0.4" - - NEO4J_VERSION="1.9.8" + # test across multiple versions of Neo4j: + - NEO4J_VERSION="2.3.1" + - NEO4J_VERSION="2.2.7" + - NEO4J_VERSION="2.1.8" + - NEO4J_VERSION="2.0.4" + +matrix: + # but we may want to allow our tests to fail against *some* Neo4j versions, + # e.g. due to unstability, bugs, or breaking changes for our test code. + allow_failures: + - env: NEO4J_VERSION="2.0.4" # seems to have transaction bugs before_install: - # install Neo4j locally: - - wget dist.neo4j.org/neo4j-community-$NEO4J_VERSION-unix.tar.gz - - tar -xzf neo4j-community-$NEO4J_VERSION-unix.tar.gz - - neo4j-community-$NEO4J_VERSION/bin/neo4j start + # install Neo4j locally: + - wget dist.neo4j.org/neo4j-community-$NEO4J_VERSION-unix.tar.gz + - tar -xzf neo4j-community-$NEO4J_VERSION-unix.tar.gz + - neo4j-community-$NEO4J_VERSION/bin/neo4j start # don't CI feature branches, but note that this *does* CI PR merge commits -- # including before they're made! =) branches: only: - master + +script: + - npm run lint + - npm test diff --git a/API_v2.md b/API_v2.md index fc72d61..3d413a6 100644 --- a/API_v2.md +++ b/API_v2.md @@ -4,6 +4,7 @@ This is a rough, work-in-progress redesign of the node-neo4j API. - [General](#general) - [Core](#core) +- [Auth](#auth) - [HTTP](#http) - [Objects](#objects) - [Cypher](#cypher) @@ -44,20 +45,49 @@ var neo4j = require('neo4j'); var db = new neo4j.GraphDatabase({ url: 'http://localhost:7474', + auth: null, // optional; see below for more details headers: {}, // optional defaults, e.g. User-Agent - proxy: '', // optional + proxy: null, // optional URL + agent: null, // optional http.Agent instance, for custom socket pooling }); ``` -An upcoming version of Neo4j will likely add native authentication. -We already support HTTP Basic Auth in the URL, but we may then need to add -ways to manage the auth (e.g. generate and reset tokens). +To specify auth credentials, the username and password can be provided either +directly in the URL, e.g. `'http://user:pass@localhost:7474'`, +or via an `auth` option, which can either be a `'username:password'` string +or a `{username, password}` object. The auth option takes precedence. + +If credentials are given in any form, they will be normalized to a +`{username, password}` object and set as the `auth` property on the +constructed `GraphDatabase` instance. +In addition, the `url` property will have its credentials cleared if an `auth` +option was provided as well. +An empty string/object `auth` can be provided to clear auth in this way; +the `auth` property will be normalized to `null` in this case. The current v1 of the driver is hypermedia-driven, so it discovers the `/db/data` endpoint. We may hardcode that in v2 for efficiency and simplicity, but if we do, do we need to make that customizable/overridable too? +## Auth + +**Let me manage database authentication.** + +Note that this section only matters for *managing* auth, not *specifying* it. + +```js +function cbBool(err, bool) {} +function cbDone(err) {} + +db.checkPasswordChangeNeeded(cbBool); +db.changePassword({password}, cbDone); +``` + +For convenience, changing the password will automatically update the `auth` +property on the `GraphDatabase` instance, so that subsequent requests will work. + + ## HTTP **Let me make arbitrary HTTP requests to the REST API.** @@ -65,17 +95,26 @@ but if we do, do we need to make that customizable/overridable too? This will allow callers to make any API requests; no one will be blocked by this driver not supporting a particular API. -It'll also allow callers to interface with arbitrary plugins, -including custom ones. +It'll also allow callers to interface with arbitrary Neo4j plugins +(e.g. [neo4j-spatial](http://neo4j-contrib.github.io/spatial/#spatial-server-plugin)), +even custom ones. ```js -function cb(err, body) {}; +function cb(err, body) {} var req = db.http({method, path, headers, body, raw}, cb); ``` -This method will immediately return a duplex HTTP stream, to and from which -both request and response body data can be piped or streamed. +This method returns a [`Request.js`][] instance, which is a duplex stream +to and from which both request and response data can be streamed. + +(`Request.js` provides a number of benefits over the native HTTP +[`ClientRequest`][] and [`IncomingMessage`][] classes, such as proxy support, +gzip decompression, simpler writes, and a single, unified `'error'` event.) + +[`Request.js`]: https://github.com/request/request +[`ClientRequest`]: http://nodejs.org/api/http.html#http_class_http_clientrequest +[`IncomingMessage`]: http://nodejs.org/api/http.html#http_http_incomingmessage In addition, if a callback is given, it will be called with the final result. By default, this result will be the HTTP response body (parsed as JSON), @@ -90,14 +129,6 @@ The `body` will still be parsed as JSON, but nodes, relationships, and errors will *not* be transformed to node-neo4j objects in this case. In addition, `4xx` and `5xx` status code will *not* yield an error. -Importantly, we don't want to leak the implementation details of which HTTP -library we use. Both [request](https://github.com/request/request) and -[SuperAgent](http://visionmedia.github.io/superagent/#piping-data) are great; -it'd be nice to experiment with both (e.g. SuperAgent supports the browser). -Does this mean we should do anything special when returning HTTP responses? -E.g. should we document our own minimal HTTP `Response` interface that's the -common subset of both libraries? - ## Objects @@ -144,9 +175,12 @@ from database responses. **Let me make simple, parametrized Cypher queries.** ```js -function cb(err, results) {}; +function cb(err, results) {} -var stream = db.cypher({query, params, headers, raw}, cb); +var stream = db.cypher({query, params, headers, lean}, cb); + +// Alternate simple version -- no params, no headers, not lean: +var stream = db.cypher(query, cb); ``` If a callback is given, it'll be called with the array of results, @@ -160,7 +194,7 @@ row value for that column. In addition, by default, nodes and relationships will be transformed to `Node` and `Relationship` objects. If you don't need the full knowledge of node and relationship metadata -(labels, types, native IDs), you can bypass that by specifying `raw: true`, +(labels, types, native IDs), you can bypass that by specifying `lean: true`, which will return just property data, for a potential performance gain. TODO: Should we formalize the streaming case into a documented Stream class? @@ -169,67 +203,80 @@ Or should we just link to cypher-stream? TODO: Should we allow access to other underlying data formats, e.g. "graph"? +## Batching + +**Let me make multiple, separate Cypher queries in one network request.** + +```js +function cbMany(err, arraysOfResults) {} + +var streams = db.cypher({queries, headers}, cbMany); + +// Alternate simple version -- no custom headers: +var streams = db.cypher(queries, cbMany); +``` + +In both cases, `queries` is an array of queries, where each query can be a +`{query, params, lean}` object or a simple string. + +**Important:** batch queries are executed transactionally — +either they all succeed, or they all fail. + +If a callback is given, it'll be called with an array containing an array of +results for each query (in matching order), or an error if there is any. +Alternately, the results can be streamed back by omitting the callback. +In that case, an array will be returned, containing a stream for each query +(in matching order). + +TODO: Is it valuable to return a stream of results *across all queries*? + + ## Transactions **Let me make multiple queries, across multiple network requests, all within a single transaction.** -This is the trickiest part of the API to design. -I've tried my best to design this using the use cases we have at FiftyThree, -but it's very hard to know whether this is designed well for a broader set of -use cases without having more experience or feedback. - -Example use case: complex delete. -I want to delete an image, which has some image-specific business logic, -but in addition, I need to delete any likes and comments on the image. -Each of those has its own specific business logic (which may also be -recursive), so our code can't capture everything in a single query. -Thus, we need to make one query to delete the comments and likes (which may -actually be multiple queries, as well), then a second one to delete the image. -We want to do all of that transactionally, so that if any one query fails, -we abort/rollback and either retry or report failure to the user. - -Given a use case like that, this API is optimized for **one query per network -request**, *not* multiple queries per network request ("batching"). -I *think* batching is always an optimization (never a true *requirement*), -so it could always be achieved automatically under-the-hood by this driver -(e.g. by waiting until the next event loop tick to send the actual queries). -Please provide feedback if you disagree! - ```js -var tx = db.beginTransaction(); // any options needed? +var tx = db.beginTransaction(); ``` -This method returns a `Transaction` object, which mainly just encapsulates the -state of a "transaction ID" returned by Neo4j from the first query. - This method is named "begin" instead of "create" to reflect that it returns immediately, and has not actually persisted anything to the database yet. +This method returns a `Transaction` object, which mainly just encapsulates the +state of a "transaction ID" returned by Neo4j from the first query. +In addition, though, `Transaction` instances expose helpful properties to let +you know a transaction's precise state, e.g. whether it was automatically +rolled back by Neo4j due to a fatal error. +This precise state tracking also lets this driver prevent predictable errors, +which may be signs of code bugs, and provide more helpful error messaging. + ```coffee -class Transaction {_id} +class Transaction {_id, expiresAt, expiresIn, state} +# `expiresAt` is a Date, while `expiresIn` is a millisecond count. +# `state` is one of 'open', 'pending', 'committed, 'rolled back', or 'expired', +# all of which are constant properties on instances, e.g. `STATE_ROLLED_BACK`. ``` ```js -function cbResults(err, results) {}; -function cbDone(err) {}; +function cbResults(err, results) {} +function cbDone(err) {} -var stream = tx.cypher({query, params, headers, raw, commit}, cbResults); +var stream = tx.cypher({query, params, headers, lean, commit}, cbResults); tx.commit(cbDone); tx.rollback(cbDone); +tx.renew(cbDone); ``` -The transactional `cypher` method is just like the regular `cypher` method, +The transactional `cypher` method is just like the regular `cypher` method +(e.g. it supports simple strings, as well as batching), except that it supports an additional `commit` option, which can be set to -`true` to automatically attempt to commit the transaction after this query. +`true` to automatically attempt to commit the transaction with this query. Otherwise, transactions can be committed and rolled back independently. - -TODO: Any more functionality needed for transactions? -There's a notion of expiry, and the expiry timeout can be reset by making -empty queries; should a notion of auto "renewal" (effectively, a higher -timeout than the default) be built-in for convenience? +They can also be explicitly renewed, as Neo4j expires them if idle too long, +but every query in a transaction implicitly renews the transaction as well. ## Errors @@ -308,8 +355,10 @@ using `Error` subclasses. Importantly: - Special care is taken to provide `message` and `stack` properties rich in info, so that no special serialization is needed to debug production errors. + That is, simply logging the `stack` (as most error handlers tend to do) + should get you meaningful and useful data. -- And all info returned by Neo4j is also available on the `Error` instances +- Structured info returned by Neo4j is also available on the `Error` instances under a `neo4j` property, for deeper introspection and analysis if desired. ```coffee @@ -320,9 +369,9 @@ class DatabaseError extends Error class TransientError extends Error ``` -TODO: Should we name these classes with a `Neo4j` prefix? -They'll only be exposed via this driver's `module.exports`, so it's not -technically necessary, but that'd allow for e.g. destructuring. +Note that these class names do *not* have a `Neo4j` prefix, since they'll only +be exposed via this driver's `module.exports`, but for convenience, instances' +`name` properties *do* have a `neo4j.` prefix, e.g. `neo4j.ClientError`. ## Schema @@ -337,7 +386,7 @@ for those boilerplate Cypher queries. ### Labels ```js -function cb(err, labels) {}; +function cb(err, labels) {} db.getLabels(cb); ``` @@ -353,16 +402,19 @@ Labels are simple strings. ### Indexes ```js -function cbOne(err, index) {}; -function cbMany(err, indexes) {}; -function cbBool(err, bool) {}; -function cbDone(err) {}; +function cbOne(err, index) {} +function cbMany(err, indexes) {} +function cbBool(err, bool) {} +function cbDone(err) {} db.getIndexes(cbMany); // across all labels db.getIndexes({label}, cbMany); // for a particular label db.hasIndex({label, property}, cbBool); db.createIndex({label, property}, cbOne); -db.deleteIndex({label, property}, cbDone); + // callback receives null if this index already exists +db.dropIndex({label, property}, cbBool); + // callback receives true if this index existed and was dropped, + // false if it didn't exist in the first place ``` Returned indexes are minimal `Index` objects: @@ -375,41 +427,54 @@ TODO: Neo4j's REST API actually takes and returns *arrays* of properties, but AFAIK, all indexes today only deal with a single property. Should multiple properties be supported? +TODO: Today, there's no need for a `db.getIndex()` method, since the parameters +you need to fetch an index are the same ones that are returned. +But if Neo4j adds extra info to the response (e.g. online/offline status), +we should add that method, along with that extra info in our `Index` class. + ### Constraints The only constraint type implemented by Neo4j today is the uniqueness -constraint, so this API defaults to that. -The design aims to be generic in order to support future constraint types, -but it's still possible that the API may have to break when that happens. +constraint, so this API assumes that. +Because of that, it's possible that this API may have to break whenever new +constraint types are added (whatever they may be). ```js -function cbOne(err, constraint) {}; -function cbMany(err, constraints) {}; -function cbBool(err, bool) {}; -function cbDone(err) {}; +function cbOne(err, constraint) {} +function cbMany(err, constraints) {} +function cbBool(err, bool) {} +function cbDone(err) {} db.getConstraints(cbMany); // across all labels db.getConstraints({label}, cbMany); // for a particular label db.hasConstraint({label, property}, cbBool); db.createConstraint({label, property}, cbOne); -db.deleteConstraint({label, property}, cbDone); + // callback receives null if this constraint already exists +db.dropConstraint({label, property}, cbDone); + // callback receives true if this constraint existed and was dropped, + // false if it didn't exist in the first place ``` Returned constraints are minimal `Constraint` objects: ```coffee -class Constraint {label, type, property} +class Constraint {label, property} ``` TODO: Neo4j's REST API actually takes and returns *arrays* of properties, but uniqueness constraints today only deal with a single property. Should multiple properties be supported? +TODO: Today, there's no need for a `db.getConstraint()` method, since the +parameters you need to fetch a constraint are the same ones that are returned. +But if Neo4j adds extra info to the response (e.g. online/offline status), +we should add that method, along with that extra info in our `Constraint` class. + ### Misc ```js -function cbKeys(err, keys) {}; -function cbTypes(err, types) {}; +function cbKeys(err, keys) {} +function cbTypes(err, types) {} db.getPropertyKeys(cbKeys); db.getRelationshipTypes(cbTypes); @@ -426,9 +491,9 @@ This driver thus provides legacy indexing APIs. ### Management ```js -function cbOne(err, index) {}; -function cbMany(err, indexes) {}; -function cbDone(err) {}; +function cbOne(err, index) {} +function cbMany(err, indexes) {} +function cbDone(err) {} db.getLegacyNodeIndexes(cbMany); db.getLegacyNodeIndex({name}, cbOne); @@ -454,9 +519,9 @@ The `config` property is e.g. `{provider: 'lucene', type: 'fulltext'}`; ### Simple Usage ```js -function cbOne(err, node_or_rel) {}; -function cbMany(err, nodes_or_rels) {}; -function cbDone(err) {}; +function cbOne(err, node_or_rel) {} +function cbMany(err, nodes_or_rels) {} +function cbDone(err) {} db.addNodeToLegacyIndex({name, key, value, _id}, cbOne); db.getNodesFromLegacyIndex({name, key, value}, cbMany); // key-value lookup @@ -487,7 +552,7 @@ For adding existing nodes or relationships, simply pass `unique: true` to the `add` method. ```js -function cb(err, node_or_rel) {}; +function cb(err, node_or_rel) {} db.addNodeToLegacyIndex({name, key, value, _id, unique: true}, cb); db.addRelationshipToLegacyIndex({name, key, value, _id, unique: true}, cb); @@ -501,7 +566,7 @@ For creating new nodes or relationships, the `create` method below corresponds with "create or fail", while `getOrCreate` corresponds with "get or create": ```js -function cb(err, node_or_rel) {}; +function cb(err, node_or_rel) {} db.createNodeFromLegacyIndex({name, key, value, properties}, cb); db.getOrCreateNodeFromLegacyIndex({name, key, value, properties}, cb); @@ -523,8 +588,8 @@ just replace `LegacyIndex` with `LegacyAutoIndex` in all method names, then omit the `name` parameter. ```js -function cbOne(err, node_or_rel) {}; -function cbMany(err, nodes_or_rels) {}; +function cbOne(err, node_or_rel) {} +function cbMany(err, nodes_or_rels) {} db.getNodesFromLegacyAutoIndex({key, value}, cbMany); // key-value lookup db.getNodesFromLegacyAutoIndex({query}, cbMany); // arbitrary Lucene query diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..14697e4 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,113 @@ +## Issues + +Bug reports and feature requests are always welcome via [GitHub Issues](https://github.com/thingdom/node-neo4j/issues). General questions and troubleshooting are better served by the [mailing list](https://groups.google.com/group/node-neo4j) and [Stack Overflow](http://stackoverflow.com/questions/ask?tags=node.js,neo4j). + +For bug reports, please try to include: + +- Neo4j version (`curl -s localhost:7474/db/data/ | grep version`) +- Node.js version (`node --version`) +- node-neo4j version (`npm ls neo4j`) +- npm version (`npm --version`) +- Example code, if possible (pro tip: [create a gist](https://gist.github.com/) if there's a lot) + +For feature requests, real-world use cases are always helpful. + + +## Pull Requests + +If you're comfortable rolling up your sleeves and contributing code (instructions below), great! Fork this repo, start a new branch, and pull request away, but please follow these guidelines: + +- Follow the existing code style. (We use [CoffeeLint](http://coffeelint.org/) to check it.) `npm run lint` should continue to pass. + +- Add or update tests as appropriate. (You can look at the existing tests for reference.) `npm test` should continue to pass. + +- Add or update documentation similarly. But don't stress too much about it; we'll likely polish documentation ourselves before release anyway. + +- If this main repo's `master` branch has moved forward since you began, do merge the latest changes into your branch (or rebase your branch if you're comfortable with that). + +Thanks! We'll try to review your pull request as soon as we can. + + +## Development + +To get set up for development, after cloning this repository: + +```sh +npm install && npm run clean +``` + +You'll need a [local installation of Neo4j](http://neo4j.org/download), and it should be running on the default port of 7474. + +To run the tests: + +```sh +npm test +``` + +This library is written in [CoffeeScript](http://coffeescript.org/), and we lint the code with [CoffeeLint](http://coffeelint.org/). To lint: + +```sh +npm run lint +``` + +The tests automatically compile the CoffeeScript on-the-fly, but you can also generate `.js` files from the source `.coffee` files manually: + +```sh +npm run build +``` + +This is in fact what's run on `prepublish` for npm. But please don't check the generated `.js` files in; to remove: + +```sh +npm run clean +``` + +When compiled `.js` files exist, changes to the source `.coffee` files will *not* be picked up automatically; you'll need to rebuild. + +If you `npm link` this module and you want the code compiled on-the-fly during development, you can create an `exports.js` file under `lib-new/` with the following: + +```js +require('coffee-script/register'); +module.exports = require('./exports.coffee'); +``` + +But don't check this in! That would cause all clients to compile the code on-the-fly every time, which isn't desirable in production. + + +## Testing + +This library strives for thorough test coverage. Every major feature has corresponding tests. + +The tests run on [Mocha](http://mochajs.org/) and use [Chai](http://chaijs.com/) for assertions. In addition, the code is written in [Streamline.js](https://github.com/Sage/streamlinejs) syntax, for convenience and robustness. + +In a nutshell, instead of calling async functions with a callback (which receives an error and a result), we get to call them with an `_` parameter instead — and then pretend as if the functions are synchronous (*returning* their results and *throwing* any errors). + +E.g. instead of writing tests like this: + +```coffee +it 'should get foo then set bar', (done) -> + db.getFoo (err, foo) -> + return done err if err + expect(foo).to.be.a 'number' + + db.setBar foo, (err, bar) -> + return done err if err + expect(bar).to.equal foo + + done() +``` + +We get to write tests like this: + +```coffee +it 'should get foo then set bar', (_) -> + foo = db.getFoo _ + expect(foo).to.be.a 'number' + + bar = db.setBar foo, _ + expect(bar).to.equal foo +``` + +This lets us write more concise tests that simultaneously test for errors more thoroughly. (If an async function "throws" an error, the test will fail.) + +It's important for our tests to pass across multiple versions of Neo4j, and they should also be robust to existing data (e.g. labels and constraints) in the database. To that end, they should generate data for testing, and then clean up that data at the end. diff --git a/README.md b/README.md index 2f16be7..4ef2aae 100644 --- a/README.md +++ b/README.md @@ -1,132 +1,590 @@ -[![Build Status](https://travis-ci.org/thingdom/node-neo4j.svg?branch=master)](https://travis-ci.org/thingdom/node-neo4j) - # Node-Neo4j -This is a client library for accessing [Neo4j][], a graph database, from -[Node.js][]. It uses Neo4j's [REST API][neo4j-rest-api], and supports -Neo4j 1.5 through Neo4j 2.1. +[![npm version](https://badge.fury.io/js/neo4j.svg)](http://badge.fury.io/js/neo4j) [![Build Status](https://travis-ci.org/thingdom/node-neo4j.svg?branch=master)](https://travis-ci.org/thingdom/node-neo4j) + +[Node.js](http://nodejs.org/) driver for [Neo4j](http://neo4j.com/), a graph database. + +This driver aims to be the most **robust**, **comprehensive**, and **battle-tested** driver available. It's run in production by [FiftyThree](https://www.fiftythree.com/) to power [Paper](https://www.fiftythree.com/paper) and [Mix](https://mix.fiftythree.com/). -(Note that new 2.0 features like labels and constraints are only accessible -through Cypher for now -- but Cypher is the recommended interface for Neo4j -anyway. This driver might change to wrap Cypher entirely.) +*(Note: if you're still on Neo4j 1.x, you'll need to use [node-neo4j 1.x](https://github.com/thingdom/node-neo4j/tree/v1) as well.)* -**Update: [node-neo4j v2][] is under development and almost finished!** -[Read the full spec here][v2 spec], and follow the progress in [pull #145][]. -If you're comfortable using pre-release code, alpha versions are available on -npm; we at [FiftyThree][] are running them in production. =) -[node-neo4j v2]: https://github.com/thingdom/node-neo4j/tree/v2#readme -[v2 spec]: https://github.com/thingdom/node-neo4j/blob/v2/API_v2.md -[pull #145]: https://github.com/thingdom/node-neo4j/pull/145 -[FiftyThree]: http://www.fiftythree.com/ +## Features + +- [**Cypher queries**](#cypher), parameters, [batching](#batching), and [**transactions**](#transactions) +- Arbitrary [**HTTP requests**](#http-plugins), for custom [Neo4j plugins](#http-plugins) +- [**Custom headers**](#headers), for [**high availability**](#high-availability), application tracing, query logging, and more +- [**Precise errors**](#errors), for robust error handling from the start +- Configurable [**connection pooling**](#tuning), for performance tuning & monitoring +- Thorough test coverage with [**>100 tests**](./test-new) +- [**Continuously integrated**](https://travis-ci.org/thingdom/node-neo4j) against [multiple versions](./.travis.yml) of Node.js and Neo4j ## Installation - npm install neo4j@1.x --save +```sh +npm install neo4j --save +``` -## Usage +## Example -To start, create a new instance of the `GraphDatabase` class pointing to your -Neo4j instance: +```js +var neo4j = require('neo4j'); +var db = new neo4j.GraphDatabase('http://username:password@localhost:7474'); + +db.cypher({ + query: 'MATCH (user:User {email: {email}}) RETURN user', + params: { + email: 'alice@example.com', + }, +}, callback); + +function callback(err, results) { + if (err) throw err; + var result = results[0]; + if (!result) { + console.log('No user found.'); + } else { + var user = result['user']; + console.log(user); + } +}; +``` + +Yields e.g.: + +```json +{ + "_id": 12345678, + "labels": [ + "User", + "Admin" + ], + "properties": { + "name": "Alice Smith", + "email": "alice@example.com", + "emailVerified": true, + "passwordHash": "..." + } +} +``` + +See [node-neo4j-template](https://github.com/aseemk/node-neo4j-template) for a more thorough example. + +TODO: Also link to movies example. + + +## Basics + +Connect to a running Neo4j instance by instantiating the **`GraphDatabase`** class: ```js var neo4j = require('neo4j'); -var db = new neo4j.GraphDatabase('http://localhost:7474'); + +// Shorthand: +var db = new neo4j.GraphDatabase('http://username:password@localhost:7474'); + +// Full options: +var db = new neo4j.GraphDatabase({ + url: 'http://localhost:7474', + auth: {username: 'username', password: 'password'}, + // ... +}); +``` + +Options: + +- **`url` (required)**: the base URL to the Neo4j instance, e.g. `'http://localhost:7474'`. This can include auth credentials (e.g. `'http://username:password@localhost:7474'`), but doesn't have to. + +- **`auth`**: optional auth credentials; either a `'username:password'` string, or a `{username, password}` object. If present, this takes precedence over any credentials in the `url`. + +- **`headers`**: optional custom [HTTP headers](#headers) to send with every request. These can be overridden per request. Node-Neo4j defaults to sending a `User-Agent` identifying itself, but this can be overridden too. + +- **`proxy`**: optional URL to a proxy. If present, all requests will be routed through the proxy. + +- **`agent`**: optional [`http.Agent`](http://nodejs.org/api/http.html#http_http_agent) instance, for custom [socket pooling](#tuning). + +Once you have a `GraphDatabase` instance, you can make queries and more. + +Most operations are **asynchronous**, which means they take a **callback**. Node-Neo4j callbacks are of the standard `(error[, results])` form. + +Async control flow can get pretty tricky, so it's *highly* recommended to use a flow control library or tool, like [async](https://github.com/caolan/async) or [Streamline](https://github.com/Sage/streamlinejs). + + +## Cypher + +To make a [Cypher query](http://neo4j.com/docs/stable/cypher-query-lang.html), simply pass the string query, any query parameters, and a callback to receive the error or results. + +```js +db.cypher({ + query: 'MATCH (user:User {email: {email}}) RETURN user', + params: { + email: 'alice@example.com', + }, +}, callback); +``` + +It's extremely important to **pass `params` separately**. If you concatenate them into the `query`, you'll be vulnerable to injection attacks, and Neo4j performance will suffer as well. + +Cypher queries *always* return a list of results (like SQL rows), with each result having common properties (like SQL columns). Thus, query **results** passed to the callback are *always* an **array** (even if it's empty), and each **result** in the array is *always* an **object** (even if it's empty). + +```js +function callback(err, results) { + if (err) throw err; + var result = results[0]; + if (!result) { + console.log('No user found.'); + } else { + var user = result['user']; + console.log(user); + } +}; +``` + +If the query results include nodes or relationships, **`Node`** and **`Relationship` instances** are returned for them. These instances encapsulate `{_id, labels, properties}` for nodes, and `{_id, type, properties, _fromId, _toId}` for relationships, but they can be used just like normal objects. + +```json +{ + "_id": 12345678, + "labels": [ + "User", + "Admin" + ], + "properties": { + "name": "Alice Smith", + "email": "alice@example.com", + "emailVerified": true, + "passwordHash": "..." + } +} ``` -Node.js is asynchronous, which means this library is too: most functions take -callbacks and return immediately, with the callbacks being invoked when the -corresponding HTTP requests and responses finish. +(The `_id` properties refer to Neo4j's internal IDs. These can be convenient for debugging, but their usage otherwise — especially externally — is discouraged.) -Here's a simple example: +If you don't need to know Neo4j IDs, node labels, or relationship types, you can pass **`lean: true`** to get back *just* properties, for a potential performance gain. ```js -var node = db.createNode({hello: 'world'}); // instantaneous, but... -node.save(function (err, node) { // ...this is what actually persists. - if (err) { - console.error('Error saving new node to database:', err); +db.cypher({ + query: 'MATCH (user:User {email: {email}}) RETURN user', + params: { + email: 'alice@example.com', + }, + lean: true, +}, callback); +``` + +```json +{ + "name": "Alice Smith", + "email": "alice@example.com", + "emailVerified": true, + "passwordHash": "..." +} +``` + +Other options: + +- **`headers`**: optional custom [HTTP headers](#headers) to send with this query. These will add onto the default `GraphDatabase` `headers`, but also override any that overlap. + + +## Batching + +Although this need is rare, you can make multiple Cypher queries in a single network request, by passing a `queries` *array* rather than a single `query` string. + +Query `params` (and optionally `lean`) are then specified *per query*, so the elements in the array are `{query, params[, lean]}` objects. (Other options like `headers` remain "global" for the entire request.) + +```js +db.cypher({ + queries: [{ + query: 'MATCH (user:User {email: {email}}) RETURN user', + params: { + email: 'alice@example.com', + }, + }, { + query: 'MATCH (task:WorkerTask) RETURN task', + lean: true, + }, { + query: 'MATCH (task:WorkerTask) DELETE task', + }], + headers: { + 'X-Request-ID': '1234567890', + }, +}, callback); +``` + +The callback then receives an *array* of query results, one per query. + +```js +function callback(err, batchResults) { + if (err) throw err; + + var userResults = batchResults[0]; + var taskResults = batchResults[1]; + var deleteResults = batchResults[2]; + + // User results: + var userResult = userResults[0]; + if (!userResult) { + console.log('No user found.'); } else { - console.log('Node saved to database with id:', node.id); + var user = userResult['user']; + console.log('User %s (%s) found.', user._id, user.properties.name); } + + // Worker task results: + if (!taskResults.length) { + console.log('No worker tasks to process.'); + } else { + taskResults.forEach(function (taskResult) { + var task = taskResult['task']; + console.log('Processing worker task %s...', task.operation); + }); + } + + // Delete results (shouldn’t have returned any): + assert.equal(deleteResults.length, 0); +}; +``` + +Importantly, batch queries execute (a) **sequentially** and (b) **transactionally**: they all succeed, or they all fail. If you don't need them to be transactional, it can often be better to parallelize separate `db.cypher` calls instead. + + +## Transactions + +You can also batch multiple Cypher queries into a single transaction across *multiple* network requests. This can be useful when application logic needs to run in between related queries. The queries will all succeed or fail together. + +To do this, begin a new transaction, make Cypher queries within that transaction, and then ultimately commit the transaction or roll it back. + +```js +var tx = db.beginTransaction(); + +function makeFirstQuery() { + tx.cypher({ + query: '...', + params {...}, + }, makeSecondQuery); +} + +function makeSecondQuery(err, results) { + if (err) throw err; + // ...some application logic... + tx.cypher({ + query: '...', + params: {...}, + }, finish); +} + +function finish(err, results) { + if (err) throw err; + // ...some application logic... + tx.commit(done); // or tx.rollback(done); +} + +function done(err) { + if (err) throw err; + // At this point, the transaction has been committed. +} + +makeFirstQuery(); +``` + +The transactional `cypher` method supports everything the normal [`cypher`](#cypher) method does (e.g. `lean`, `headers`, and batch `queries`). In addition, you can pass **`commit: true`** to auto-commit the transaction (and save a network request) if the query succeeds. + +```js +function makeSecondQuery(err, results) { + if (err) throw err; + // ...some application logic... + tx.cypher({ + query: '...', + params: {...}, + commit: true, + }, done); +} + +function done(err) { + if (err) throw err; + // At this point, the transaction has been committed. +} +``` + +Importantly, transactions allow only one query at a time. To help preempt errors, you can inspect the **`state`** of the transaction, e.g. whether it's open for queries or not. + +```js +// Initially, transactions are open: +assert.equal(tx.state, tx.STATE_OPEN); + +// Making a query... +tx.cypher({ + query: '...', + params: {...}, +}, callback) + +// ...will result in the transaction being pending: +assert.equal(tx.state, tx.STATE_PENDING); + +// All other operations (making another query, committing, etc.) +// are rejected while the transaction is pending: +assert.throws(tx.renew.bind(tx)) + +function callback(err, results) { + // When the query returns, the transaction is likely open again, + // but it could be committed if `commit: true` was specified, + // or it could have been rolled back automatically (by Neo4j) + // if there was an error: + assert.notEqual([ + tx.STATE_OPEN, tx.STATE_COMMITTED, tx.STATE_ROLLED_BACK + ].indexOf(tx.state), -1); // i.e. tx.state is in this array +} +``` + +Finally, open transactions **expire** after some period of inactivity. This period is [configurable in Neo4j](http://neo4j.com/docs/stable/server-configuration.html), but it defaults to 60 seconds today. Transactions **renew automatically** on every query, but if you need to, you can inspect transactions' expiration times and renew them manually. + +```js +// Only open transactions (not already expired) can be renewed: +assert.equal(tx.state, tx.STATE_OPEN); +assert.notEqual(tx.state, tx.STATE_EXPIRED); + +console.log('Before:', tx.expiresAt, '(in', tx.expiresIn, 'ms)'); +tx.renew(function (err) { + if (err) throw err; + console.log('After:', tx.expiresAt, '(in', tx.expiresIn, 'ms)'); }); ``` -Because async flow in Node.js can be quite tricky to handle, we -strongly recommend using a flow control tool or library to help. -Our personal favorite is [Streamline.js][], but other popular choices are -[async](https://github.com/caolan/async), -[Step](https://github.com/creationix/step), -[Seq](https://github.com/substack/node-seq), [TameJS](http://tamejs.org/) and -[IcedCoffeeScript](http://maxtaco.github.com/coffee-script/). +The full [state diagram](https://mix.fiftythree.com/aseemk/2462211) putting this all together: -Once you've gotten the basics down, skim through the full -**[API documentation][api-docs]** to see what this library can do, and take a -look at [@aseemk][aseemk]'s [node-neo4j-template][] app for a complete usage -example. (The `models/User.js` file in particular is the one that interacts -with this library.) +[![Neo4j transaction state diagram](https://d3ayzibdlq49a1.cloudfront.net/f2a67a92-8b73-44ba-bb29-0ce12069c57e/image/f2a67a92-8b73-44ba-bb29-0ce12069c57e_image_2048x1536.png)](https://mix.fiftythree.com/aseemk/2462211) -This library is officially stable at "v1", but "v2" will almost certainly have -breaking changes to support only Neo4j 2.0 and generally improve the API -([roadmap][]). You can be sheltered from these changes if you simply specify -your package.json dependency as e.g. `1.x` or `^1.0` instead of `*`. -[Roadmap]: https://github.com/thingdom/node-neo4j/wiki/Roadmap +## Headers +Most node-neo4j operations support passing in custom headers for the underlying HTTP requests. The `GraphDatabase` constructor also supports passing in default headers for every operation. -## Development +This can be useful to achieve a variety of features, such as: - git clone git@github.com:thingdom/node-neo4j.git - cd node-neo4j - npm install && npm run clean +- Logging individual queries +- Tracing application requests +- Splitting master/slave traffic (see [High Availability](#high-availability) below) -You'll need a local installation of Neo4j ([links](http://neo4j.org/download)), -and it should be running on the default port of 7474 (`neo4j start`). +None of these things are supported out-of-the-box by Neo4j today, but all can be handled by a server (e.g. Apache or Nginx) or load balancer (e.g. HAProxy or Amazon ELB) in front. -To run the tests: +For example, at FiftyThree, our Cypher requests look effectively like this (though we abstract and encapsulate these things with higher-level helpers): - npm test +```js +db.cypher({ + query: '...', + params: {...}, + headers: { + // Identify the query via a short, human-readable name. + // This is what we log in HAProxy for every request, + // since all Cypher calls have the same HTTP path, + // and this is friendlier than the entire query. + 'X-Query-Name': 'User_getUnreadNotifications', + + // This tells HAProxy to send this query to the master (even + // though it's a read), as we require strong consistency here. + // See the High Availability section below. + 'X-Consistency': 'strong' + + // This is a concatenation of upstream services' request IDs + // along with a randomly generated one of our own. + // We log this header on all our servers, so we can trace + // application requests through our entire stack. + // TODO: Link to Heroku article on this! + 'X-Request-Ids': '123,456,789' + }, +}, callback); +``` + +You might also find custom headers helpful for custom [Neo4j plugins](#http-plugins). -This library is written in [CoffeeScript][], using [Streamline.js][] syntax. -The tests automatically compile the code on-the-fly, but you can also generate -compiled `.js` files from the source `._coffee` files manually: - npm run build +## High Availability -This is in fact what's run each time this library is published to npm. -But please don't check the generated `.js` files in; to remove: +Neo4j Enterprise supports running multiple instances of Neo4j in a single ["High Availability"](http://neo4j.com/docs/stable/ha.html) (HA) cluster. Neo4j's HA uses a master-slave setup, so slaves typically lag behind the master by a small delay ([tunable in Neo4j](http://neo4j.com/docs/stable/ha-configuration.html)). - npm run clean +There are multiple ways to interface with an HA cluster from node-neo4j, but the [recommended route](http://neo4j.com/docs/stable/ha-haproxy.html) is to place a **load balancer** in front (e.g. HAProxy or Amazon ELB). You can then point node-neo4j to the load balancer's endpoint. -When compiled `.js` files exist, changes to the source `._coffee` files will -*not* be picked up automatically; you'll need to rebuild. +```js +var db = new neo4j.GraphDatabase({ + url: 'https://username:password@lb-neo4j.example.com:1234', +}); +``` -If you `npm link` this module into another app (like [node-neo4j-template][]) -and you want the code compiled on-the-fly during development, you can create -an `index.js` file under `lib/` with the following: +You'll still want to **split traffic** between the master and the slaves (e.g. reads to slaves, writes to master), in order to distribute load and improve performance. You can achieve this through [multiple ways](http://blog.armbruster-it.de/2015/08/neo4j-and-haproxy-some-best-practices-and-tricks/): + +- Create separate `GraphDatabase` instances with different `url`s to the load balancer (e.g. different host, port, or path). The load balancer can inspect the URL to route queries appropriately. + +- Use the same, single `GraphDatabase` instance, but send a [custom header](#headers) to let the load balancer know where the query should go. This is what we do at FiftyThree, and what's shown in the custom header example above. + +- Have the load balancer derive the target automatically, e.g. by inspecting the Cypher query. This isn't recommended. =) + +With this setup, you should find node-neo4j usage with an HA cluster to be seamless. + + +## HTTP / Plugins + +If you need functionality beyond Cypher, you can make direct HTTP requests to Neo4j. This can be useful for legacy APIs (e.g. [traversals](http://neo4j.com/docs/stable/rest-api-traverse.html)), custom plugins (e.g. [neo4j-spatial](http://neo4j-contrib.github.io/spatial/#spatial-server-plugin)), or even future APIs before node-neo4j implements them. ```js -require('coffee-script/register'); -require('streamline/register'); -module.exports = require('./index._coffee'); +db.http({ + method: 'GET', + path: '/db/data/node/12345678', + // ... +}, callback); + +function callback(err, body) { + if (err) throw err; + console.log(body); +} ``` -But don't check this in! That would cause all clients to compile the code -on-the-fly every time, which isn't desirable in production. +```json +{ + "_id": 12345678, + "labels": [ + "User", + "Admin" + ], + "properties": { + "name": "Alice Smith", + "email": "alice@example.com", + "emailVerified": true, + "passwordHash": "..." + } +} +``` +By default: -## Changes +- The callback receives just the **response body** (not the status code or headers); +- Any nodes and relationships in the body are **transformed** to `Node` and `Relationship` instances (like [`cypher`](#cypher)); and +- 4xx and 5xx responses are treated as **[errors](#errors)**. -See the [Changelog][changelog] for the full history of changes and releases. +You can alternately pass **`raw: true`** for more control, in which case: +- The callback receives the ***entire* response** (with an additional `body` property); +- Nodes and relationships are ***not* transformed** into `Node` and `Relationship` instances (but the body is still parsed as JSON); and +- 4xx and 5xx responses are ***not*** treated as **errors**. -## License +```js +db.http({ + method: 'GET', + path: '/db/data/node/12345678', + raw: true, +}, callback); + +function callback(err, resp) { + if (err) throw err; + assert.equal(resp.statusCode, 200); + assert.equal(typeof resp.headers, 'object'); + console.log(resp.body); +} +``` -This library is licensed under the [Apache License, Version 2.0][license]. +```js +{ + "self": "http://localhost:7474/db/data/node/12345678", + "labels": "http://localhost:7474/db/data/node/12345678/labels", + "properties": "http://localhost:7474/db/data/node/12345678/properties", + // ... + "metadata": { + "id": 12345678, + "labels": [ + "User", + "Admin" + ] + }, + "data": { + "name": "Alice Smith", + "email": "alice@example.com", + "emailVerified": true, + "passwordHash": "..." + } +} +``` + +Other options: + +- **`headers`**: optional custom [HTTP headers](#headers) to send with this request. These will add onto the default `GraphDatabase` `headers`, but also override any that overlap. + +- **`body`**: an optional request body, e.g. for `POST` and `PUT` requests. This gets serialized to JSON. + +Requests and responses can also be **streamed** for maximum performance. The `http` method returns a [Request.js](https://github.com/request/request) instance, which is a [`DuplexStream`](https://nodejs.org/api/stream.html#stream_class_stream_duplex) combining both the writeable request stream and the readable response stream. + +*(Request.js provides a number of benefits over the native HTTP +[`ClientRequest`](http://nodejs.org/api/http.html#http_class_http_clientrequest) and [`IncomingMessage`](http://nodejs.org/api/http.html#http_http_incomingmessage) classes, e.g. proxy support, +gzip decompression, simpler writes, and a single unified `'error'` event.)* + +If you want to stream the request, be sure not to pass a `body` option. And if you want to stream the response (without having it buffer in memory), be sure not to pass a callback. You can stream the request without streaming the response, and vice versa. + +Streaming the response implies the `raw` option above: nodes and relationships are *not* transformed (as even JSON isn't parsed), and 4xx and 5xx responses are *not* treated as errors. + +```js +var req = db.http({ + method: 'GET', + path: '/db/data/node/12345678', +}); + +req.on('error', function (err) { + // Handle the error somehow. The default behavior is: + throw err; +}); + +req.on('response', function (resp) { + assert.equal(resp.statusCode, 200); + assert.equal(typeof resp.headers, 'object'); + assert.equal(typeof resp.body, 'undefined'); +}); + +var body = ''; + +req.on('data', function (chunk) { + body += chunk; +}); + +req.on('end', function () { + body = JSON.parse(body); + console.log(body); +}); +``` + + +## Errors + +(TODO) + + +## Tuning + +(TODO) + + +## Management + +(TODO) + +- change password +- get labels, etc. + + +## Help + +(TODO) -## Feedback +## Contributing + +[See CONTRIBUTING.md »](./CONTRIBUTING.md) + + + + + + +## Changes + +See the [Changelog][changelog] for the full history of changes and releases. + + +## License + +This library is licensed under the [Apache License, Version 2.0][license]. + + -[neo4j]: http://neo4j.org/ [node.js]: http://nodejs.org/ [neo4j-rest-api]: http://docs.neo4j.org/chunked/stable/rest-api.html @@ -144,7 +616,6 @@ Post questions and participate in general discussions there. [node-neo4j-template]: https://github.com/aseemk/node-neo4j-template [semver]: http://semver.org/ -[neo4j-getting-started]: http://wiki.neo4j.org/content/Getting_Started_With_Neo4j_Server [coffeescript]: http://coffeescript.org/ [streamline.js]: https://github.com/Sage/streamlinejs @@ -152,3 +623,5 @@ Post questions and participate in general discussions there. [issue-tracker]: https://github.com/thingdom/node-neo4j/issues [license]: http://www.apache.org/licenses/LICENSE-2.0.html [google-group]: https://groups.google.com/group/node-neo4j + +[stackoverflow-ask]: http://stackoverflow.com/questions/ask?tags=node.js,neo4j,thingdom diff --git a/coffeelint.json b/coffeelint.json new file mode 100644 index 0000000..8b5fa95 --- /dev/null +++ b/coffeelint.json @@ -0,0 +1,137 @@ +{ + "arrow_spacing": { + "level": "error" + }, + "braces_spacing": { + "level": "error", + "spaces": 0, + "empty_object_spaces": 0 + }, + "camel_case_classes": { + "level": "error" + }, + "coffeescript_error": { + "level": "error" + }, + "colon_assignment_spacing": { + "level": "error", + "spacing": { + "left": 0, + "right": 1 + } + }, + "cyclomatic_complexity": { + "value": 15, + "level": "warn", + "note": "Good for us to know, but we're sometimes okay with localized complexity when we have good test coverage." + }, + "duplicate_key": { + "level": "error" + }, + "empty_constructor_needs_parens": { + "level": "ignore", + "note": "We consider this idiomatic: `new NotFoundError`" + }, + "ensure_comprehensions": { + "level": "warn", + "note": "TODO: What is this?" + }, + "indentation": { + "value": 4, + "level": "error" + }, + "line_endings": { + "level": "error", + "value": "unix" + }, + "max_line_length": { + "value": 80, + "level": "error", + "limitComments": true + }, + "missing_fat_arrows": { + "level": "ignore", + "is_strict": false, + "note": "There are plenty of dynamic `this` cases beyond simple class methods: event handlers, dynamic properties, even Mocha tests." + }, + "newlines_after_classes": { + "value": 2, + "level": "warn", + "note": "We prefer this, but it won't be a show-stopper." + }, + "no_backticks": { + "level": "error" + }, + "no_debugger": { + "level": "error" + }, + "no_empty_functions": { + "level": "error" + }, + "no_empty_param_list": { + "level": "error" + }, + "no_implicit_braces": { + "level": "warn", + "strict": false, + "note": "TODO: This fails on multi-line cases sometimes. https://github.com/clutchski/coffeelint/issues/459" + }, + "no_implicit_parens": { + "strict": true, + "level": "ignore", + "note": "This is definitely idiomatic CoffeeScript: `foo a, b, c`" + }, + "no_interpolation_in_single_quotes": { + "level": "error" + }, + "no_plusplus": { + "level": "ignore", + "note": "We're okay with the use of ++ and --." + }, + "no_stand_alone_at": { + "level": "ignore", + "note": "We're okay with standalone @." + }, + "no_tabs": { + "level": "error" + }, + "no_throwing_strings": { + "level": "error" + }, + "no_trailing_semicolons": { + "level": "error" + }, + "no_trailing_whitespace": { + "level": "error", + "allowed_in_comments": false, + "allowed_in_empty_lines": false + }, + "no_unnecessary_double_quotes": { + "level": "warn", + "note": "We prefer single quotes wherever possible, but this won't be a show-stopper." + }, + "no_unnecessary_fat_arrows": { + "level": "ignore", + "note": "We're okay with preemptive uses of fat arrows." + }, + "non_empty_constructor_needs_parens": { + "level": "ignore", + "note": "Similar to `no_implicit_parens`, this is definitely idiomatic CoffeeScript: `new Foo a, b, c`" + }, + "prefer_english_operator": { + "level": "error", + "doubleNotLevel": "ignore" + }, + "space_operators": { + "level": "ignore", + "note": "TODO: We're okay with no spaces for default params (e.g. `opts={}`). Exception being worked on: https://github.com/clutchski/coffeelint/pull/438" + }, + "spacing_after_comma": { + "level": "ignore", + "note": "This is definitely idiomatic CoffeeScript: `foo a, b, c`" + }, + "transform_messes_up_line_numbers": { + "level": "warn", + "note": "This is just a recommended setting for CoffeeLint." + } +} diff --git a/lib-new/Constraint.coffee b/lib-new/Constraint.coffee new file mode 100644 index 0000000..214bacc --- /dev/null +++ b/lib-new/Constraint.coffee @@ -0,0 +1,39 @@ +lib = require '../package.json' + + +module.exports = class Constraint + + constructor: (opts={}) -> + {@label, @property} = opts + + equals: (other) -> + (other instanceof Constraint) and (@label is other.label) and + (@property is other.property) + + toString: -> + # E.g. "CONSTRAINT ON (user:User) ASSERT user.email IS UNIQUE" + node = @label.toLowerCase() + "CONSTRAINT ON (#{node}:#{@label}) + ASSERT #{node}.#{@property} IS UNIQUE" + + # + # Accepts the given raw JSON from Neo4j's REST API representing a + # constraint, and creates and returns a Constraint instance from it. + # + @_fromRaw: (obj) -> + {type, label, property_keys} = obj + + if type isnt 'UNIQUENESS' + console.warn "Unrecognized constraint type encountered: #{type}. + node-neo4j v#{lib.version} doesn’t know how to handle these. + Continuing as if it’s a UNIQUENESS constraint..." + + if property_keys.length > 1 + console.warn "Constraint (on :#{label}) with #{property_keys.length} + property keys encountered: #{property_keys.join ', '}. + node-neo4j v#{lib.version} doesn’t know how to handle these. + Continuing with only the first one." + + [property] = property_keys + + return new Constraint {label, property} diff --git a/lib-new/GraphDatabase.coffee b/lib-new/GraphDatabase.coffee new file mode 100644 index 0000000..0a9b05b --- /dev/null +++ b/lib-new/GraphDatabase.coffee @@ -0,0 +1,649 @@ +$ = require 'underscore' +assert = require 'assert' +Constraint = require './Constraint' +{Error} = require './errors' +Index = require './Index' +lib = require '../package.json' +Node = require './Node' +Relationship = require './Relationship' +Request = require 'request' +Transaction = require './Transaction' +URL = require 'url' + + +module.exports = class GraphDatabase + + + ## CORE + + # Default HTTP headers: + headers: + 'User-Agent': "node-neo4j/#{lib.version}" + + constructor: (opts={}) -> + if typeof opts is 'string' + opts = {url: opts} + + {@url, @auth, @headers, @proxy, @agent} = opts + + if not @url + throw new TypeError 'URL to Neo4j required' + + # Process auth, whether through option or URL creds or both. + # Option takes precedence, and we clear the URL creds if option given. + uri = URL.parse @url + if uri.auth and @auth? + delete uri.auth + @url = URL.format uri + + # We also normalize any given auth to an object or null: + @auth = _normalizeAuth @auth ? uri.auth + + # Extend the given headers with our defaults, but clone first: + # TODO: Do we want to special-case User-Agent? Or reject if includes + # reserved headers like Accept, Content-Type, X-Stream? + @headers or= {} + @headers = $(@headers) + .chain() + .clone() + .defaults @constructor::headers + .value() + + + ## HTTP + + http: (opts={}, cb) -> + if typeof opts is 'string' + opts = {path: opts} + + {method, path, headers, body, raw} = opts + + if not path + throw new TypeError 'Path required' + + method or= 'GET' + headers or= {} + + # Extend the given headers, both with both required and optional + # defaults, but do so without modifying the input object: + headers = $(headers) + .chain() + .clone() + .defaults @headers # These headers can be overridden... + .extend # ...while these can't. + 'X-Stream': 'true' + .value() + + # TODO: Would be good to test custom proxy and agent, but difficult. + # Same with Neo4j returning gzipped responses (e.g. through an LB). + Request + method: method + url: URL.resolve @url, path + proxy: @proxy + auth: @auth + headers: headers + agent: @agent + json: body ? true + encoding: 'utf8' + gzip: true # This is only for responses: decode if gzipped. + + # Important: only pass a callback to Request if a callback was passed + # to us. This prevents Request from buffering the response in memory + # (to parse JSON) if the caller prefers to stream the response instead. + , cb and (err, resp) => + if err + # TODO: Do we want to wrap or modify native errors? + return cb err + + if raw + # TODO: Do we want to return our own Response object? + return cb null, resp + + if err = Error._fromResponse resp + return cb err + + cb null, _transform resp.body + + + ## AUTH + + checkPasswordChangeNeeded: (cb) -> + if not @auth?.username + throw new TypeError 'No `auth` specified in constructor!' + + @http + method: 'GET' + path: "/user/#{encodeURIComponent @auth.username}" + , (err, user) -> + if err + return cb err + + cb null, user.password_change_required + + changePassword: (opts={}, cb) -> + if typeof opts is 'string' + opts = {password: opts} + + {password} = opts + + if not @auth?.username + throw new TypeError 'No `auth` specified in constructor!' + + if not password + throw new TypeError 'Password required' + + @http + method: 'POST' + path: "/user/#{encodeURIComponent @auth.username}/password" + body: {password} + , (err) => + if err + return cb err + + # Since successful, update our saved state for subsequent requests: + @auth.password = password + + # Void method: + cb null + + + ## CYPHER + + # NOTE: This method is fairly complex, in part out of necessity. + # We're okay with it since we test it throughly and emphasize its coverage. + # coffeelint: disable=cyclomatic_complexity + cypher: (opts={}, cb, _tx) -> + # coffeelint: enable=cyclomatic_complexity + if typeof opts is 'string' + opts = {query: opts} + + if opts instanceof Array + opts = {queries: opts} + + {queries, query, params, headers, lean, commit, rollback} = opts + + if not _tx and rollback + throw new Error 'Illegal state: rolling back without a transaction!' + + if commit and rollback + throw new Error 'Illegal state: both committing and rolling back!' + + if rollback and (query or queries) + throw new Error 'Illegal state: rolling back with query/queries!' + + if not _tx and commit is false + throw new TypeError 'Can’t refuse to commit without a transaction! + To begin a new transaction without committing, call + `db.beginTransaction()`, and then call `cypher` on that.' + + if not _tx and not (query or queries) + throw new TypeError 'Query or queries required' + + if query and queries + throw new TypeError 'Can’t supply both a single query + and a batch of queries! Do you have a bug in your code?' + + if queries and params + throw new TypeError 'When batching multiple queries, + params must be supplied with each query, not globally.' + + if queries and lean + throw new TypeError 'When batching multiple queries, + `lean` must be specified with each query, not globally.' + + if (commit or rollback) and not (query or queries) and not _tx._id + # (Note that we've already required query or queries if no + # transaction present, so this means a transaction is present.) + # This transaction hasn't even been created yet from Neo4j's POV + # (because transactions are created lazily), so nothing to do. + cb null, null + return + + method = 'POST' + method = 'DELETE' if rollback + + path = '/db/data/transaction' + path += "/#{_tx._id}" if _tx?._id + path += '/commit' if commit or not _tx + + # Normalize input query or queries to an array of queries always, + # but remember whether a single query was given (not a batch). + # Also handle the case where no queries were given; this is either a + # void action (e.g. rollback), or legitimately an empty batch. + if query + queries = [{query, params, lean}] + single = true + else + single = not queries # void action, *not* empty [] given + queries or= [] + + # Generate the request body by transforming each query (which is + # potentially a simple string) into Neo4j's `statement` format. + # We need to remember what result format we requested for each query. + formats = [] + body = + statements: + for query in queries + if typeof query is 'string' + query = {query} + + if query.headers + throw new TypeError 'When batching multiple queries, + custom request headers cannot be supplied per query; + they must be supplied globally.' + + {query, params, lean} = query + + # NOTE: Lowercase 'rest' matters here for parsing. + formats.push format = if lean then 'row' else 'rest' + + # NOTE: Braces needed by CoffeeLint for now. + # https://github.com/clutchski/coffeelint/issues/459 + { + statement: query + parameters: params or {} + resultDataContents: [format] + } + + # TODO: Support streaming! + # + # NOTE: Specifying `raw: true` to save on parsing work (see `_transform` + # helper at the bottom of this file) if any queries are `lean: true`. + # Easy enough for us to parse ourselves, which we do, when needed. + # + @http {method, path, headers, body, raw: true}, (err, resp) => + + if err + # TODO: Do we want to wrap or modify native errors? + # NOTE: This includes our own errors for non-2xx responses. + return cb err + + if err = Error._fromResponse resp + return cb err + + _tx?._updateFromResponse resp + + {results, errors} = resp.body + + # Parse any results first, before errors, in case this is a batch + # request, where we want to return results alongside errors. + # The top-level `results` is an array of results corresponding to + # the `statements` (queries) inputted. + # We want to transform each query's results from Neo4j's complex + # format to a simple array of dictionaries. + results = + for result, i in results + {columns, data} = result + format = formats[i] + + # The `data` for each query is an array of rows, but each of + # its elements is actually a dictionary of results keyed by + # response format. We only request one format per query. + # The value of each format is an array of rows, where each + # row is an array of column values. We transform those rows + # into dictionaries keyed by column names. Finally, we also + # parse nodes & relationships into object instances if this + # query didn't request a raw format. Phew! + $(data).pluck(format).map (row) -> + result = {} + + for column, j in columns + result[column] = row[j] + + if format is 'rest' + result = _transform result + + result + + # What exactly we return depends on how we were called: + # + # - Batch: if an array of queries were given, we always return an + # array of each query's results. + # + # - Single: if a single query was given, we always return just that + # query's results. + # + # - Void: if neither was given, we explicitly return null. + # This is for transaction actions, e.g. commit, rollback, renew. + # + # We're already set up for the batch case by default, so we only + # need to account for the other cases. + # + if single + # This means a batch of queries was *not* given, but we still + # normalized to an array of queries... + if queries.length + # This means a single query was given: + assert.equal queries.length, 1, + 'There should be *exactly* one query given.' + assert results.length <= 1, + 'There should be *at most* one set of results.' + results = results[0] + else + # This means no query was given: + assert.equal results.length, 0, + 'There should be *no* results.' + results = null + + if errors.length + # TODO: Is it possible to get back more than one error? + # If so, is it fine for us to just use the first one? + [error] = errors + err = Error._fromObject error + + cb err, results + + beginTransaction: -> + new Transaction @ + + + ## SCHEMA + + getLabels: (cb) -> + # This endpoint returns the array of labels directly: + # http://neo4j.com/docs/stable/rest-api-node-labels.html#rest-api-list-all-labels + # Hence passing the callback directly. `http` handles 4xx, 5xx errors. + @http + method: 'GET' + path: '/db/data/labels' + , cb + + getPropertyKeys: (cb) -> + # This endpoint returns the array of property keys directly: + # http://neo4j.com/docs/stable/rest-api-property-values.html#rest-api-list-all-property-keys + # Hence passing the callback directly. `http` handles 4xx, 5xx errors. + @http + method: 'GET' + path: '/db/data/propertykeys' + , cb + + getRelationshipTypes: (cb) -> + # This endpoint returns the array of relationship types directly: + # http://neo4j.com/docs/stable/rest-api-relationship-types.html#rest-api-get-relationship-types + # Hence passing the callback directly. `http` handles 4xx, 5xx errors. + @http + method: 'GET' + path: '/db/data/relationship/types' + , cb + + + ## INDEXES + + getIndexes: (opts={}, cb) -> + # Support passing no options at all, to mean "across all labels": + if typeof opts is 'function' + cb = opts + opts = {} + + # Also support passing a label directory: + if typeof opts is 'string' + opts = {label: opts} + + {label} = opts + + # Support both querying for a given label, and across all labels: + path = '/db/data/schema/index' + path += "/#{encodeURIComponent label}" if label + + @http + method: 'GET' + path: path + , (err, indexes) -> + cb err, indexes?.map Index._fromRaw + + hasIndex: (opts={}, cb) -> + {label, property} = opts + + if not (label and property) + throw new TypeError \ + 'Label and property required to query whether an index exists.' + + # NOTE: This is just a convenience method; there is no REST API endpoint + # for this directly (surprisingly, since there is for constraints). + # https://github.com/neo4j/neo4j/issues/4214 + @getIndexes {label}, (err, indexes) -> + cb err, indexes?.some (index) -> + index.label is label and index.property is property + + createIndex: (opts={}, cb) -> + {label, property} = opts + + if not (label and property) + throw new TypeError \ + 'Label and property required to create an index.' + + # Passing `raw: true` so we can handle the 409 case below. + @http + method: 'POST' + path: "/db/data/schema/index/#{encodeURIComponent label}" + body: {'property_keys': [property]} + raw: true + , (err, resp) -> + if err + return cb err + + # Neo4j returns a 409 error (w/ varying code across versions) + # if this index already exists (including for a constraint). + if resp.statusCode is 409 + return cb null, null + + # Translate all other error responses as legitimate errors: + if err = Error._fromResponse resp + return cb err + + cb err, if resp.body then Index._fromRaw resp.body + + dropIndex: (opts={}, cb) -> + {label, property} = opts + + if not (label and property) + throw new TypeError 'Label and property required to drop an index.' + + # This endpoint is void, i.e. returns nothing: + # http://neo4j.com/docs/stable/rest-api-schema-indexes.html#rest-api-drop-index + # Passing `raw: true` so we can handle the 409 case below. + @http + method: 'DELETE' + path: "/db/data/schema/index\ + /#{encodeURIComponent label}/#{encodeURIComponent property}" + raw: true + , (err, resp) -> + if err + return cb err + + # Neo4j returns a 404 response (with an empty body) + # if this index doesn't exist (has already been dropped). + if resp.statusCode is 404 + return cb null, false + + # Translate all other error responses as legitimate errors: + if err = Error._fromResponse resp + return cb err + + cb err, true # Index existed and was dropped + + + ## CONSTRAINTS + + getConstraints: (opts={}, cb) -> + # Support passing no options at all, to mean "across all labels": + if typeof opts is 'function' + cb = opts + opts = {} + + # Also support passing a label directory: + if typeof opts is 'string' + opts = {label: opts} + + # TODO: We may need to support querying within a particular `type` too, + # if any other types beyond uniqueness get added. + {label} = opts + + # Support both querying for a given label, and across all labels. + # + # NOTE: We're explicitly *not* assuming uniqueness type here, since we + # couldn't achieve consistency with vs. without a label provided. + # (The `/uniqueness` part of the path can only come after a label.) + # + path = '/db/data/schema/constraint' + path += "/#{encodeURIComponent label}" if label + + @http + method: 'GET' + path: path + , (err, constraints) -> + cb err, constraints?.map Constraint._fromRaw + + hasConstraint: (opts={}, cb) -> + # TODO: We may need to support an additional `type` param too, + # if any other types beyond uniqueness get added. + {label, property} = opts + + if not (label and property) + throw new TypeError 'Label and property required to query + whether a constraint exists.' + + # NOTE: A REST API endpoint *does* exist to get a specific constraint: + # http://neo4j.com/docs/stable/rest-api-schema-constraints.html + # But it (a) returns an array, and (b) throws a 404 if no constraint. + # https://github.com/neo4j/neo4j/issues/4214 + # For those reasons, it's actually easier to just fetch all constraints; + # no error handling needed, and array processing either way. + # + # NOTE: We explicitly *are* assuming uniqueness type here, since we + # also would if we were querying for a specific constraint. + # (The `/uniqueness` part of the path comes before the property.) + # + @http + method: 'GET' + path: "/db/data/schema/constraint\ + /#{encodeURIComponent label}/uniqueness" + , (err, constraints) -> + if err + cb err + else + cb null, constraints.some (constraint) -> + constraint = Constraint._fromRaw constraint + constraint.label is label and + constraint.property is property + + createConstraint: (opts={}, cb) -> + # TODO: We may need to support an additional `type` param too, + # if any other types beyond uniqueness get added. + {label, property} = opts + + if not (label and property) + throw new TypeError \ + 'Label and property required to create a constraint.' + + # NOTE: We explicitly *are* assuming uniqueness type here, since + # that's our only option today for creating constraints. + # NOTE: Passing `raw: true` so we can handle the 409 case below. + @http + method: 'POST' + path: "/db/data/schema/constraint\ + /#{encodeURIComponent label}/uniqueness" + body: {'property_keys': [property]} + raw: true + , (err, resp) -> + if err + return cb err + + # Neo4j returns a 409 error (w/ varying code across versions) + # if this constraint already exists. + if resp.statusCode is 409 + return cb null, null + + # Translate all other error responses as legitimate errors: + if err = Error._fromResponse resp + return cb err + + cb err, if resp.body then Constraint._fromRaw resp.body + + dropConstraint: (opts={}, cb) -> + # TODO: We may need to support an additional `type` param too, + # if any other types beyond uniqueness get added. + {label, property} = opts + + if not (label and property) + throw new TypeError \ + 'Label and property required to drop a constraint.' + + # This endpoint is void, i.e. returns nothing: + # http://neo4j.com/docs/stable/rest-api-schema-constraints.html#rest-api-drop-constraint + # Passing `raw: true` so we can handle the 409 case below. + @http + method: 'DELETE' + path: "/db/data/schema/constraint/#{encodeURIComponent label}\ + /uniqueness/#{encodeURIComponent property}" + raw: true + , (err, resp) -> + if err + return cb err + + # Neo4j returns a 404 response (with an empty body) + # if this constraint doesn't exist (has already been dropped). + if resp.statusCode is 404 + return cb null, false + + # Translate all other error responses as legitimate errors: + if err = Error._fromResponse resp + return cb err + + cb err, true # Constraint existed and was dropped + + + # TODO: Legacy indexing + + +## HELPERS + +# +# Normalizes the given auth value, which can be a 'username:password' string +# or a {username, password} object, to an object or null always. +# +_normalizeAuth = (auth) -> + # Support empty string for no auth: + return null if not auth + + # Parse string if given, being robust to colons in the password: + if typeof auth is 'string' + [username, passwordParts...] = auth.split ':' + password = passwordParts.join ':' + auth = {username, password} + + # Support empty object for no auth also: + return null if (Object.keys auth).length is 0 + + auth + +# +# Deep inspects the given object -- which could be a simple primitive, a map, +# an array with arbitrary other objects, etc. -- and transforms any objects that +# look like nodes and relationships into Node and Relationship instances. +# Returns the transformed object, and does not mutate the input object. +# +_transform = (obj) -> + # Nothing to transform for primitives and null: + if (not obj) or (typeof obj isnt 'object') + return obj + + # Process arrays: + # (NOTE: Not bothering to detect arrays made in other JS contexts.) + if obj instanceof Array + return obj.map _transform + + # Feature-detect (AKA "duck-type") Node & Relationship objects, by simply + # trying to parse them as such. + # Important: check relationships first, for precision/specificity. + # TODO: If we add a Path class, we'll need to check for that here too. + if rel = Relationship._fromRaw obj + return rel + if node = Node._fromRaw obj + return node + + # Otherwise, process as a dictionary/map: + map = {} + for key, val of obj + map[key] = _transform val + map diff --git a/lib-new/Index.coffee b/lib-new/Index.coffee new file mode 100644 index 0000000..d807093 --- /dev/null +++ b/lib-new/Index.coffee @@ -0,0 +1,29 @@ + +module.exports = class Index + + constructor: (opts={}) -> + {@label, @property} = opts + + equals: (other) -> + (other instanceof Index) and (@label is other.label) and + (@property is other.property) + + toString: -> + "INDEX ON :#{@label}(#{@property})" # E.g. "INDEX ON :User(email)" + + # + # Accepts the given raw JSON from Neo4j's REST API representing an index, + # and creates and returns a Index instance from it. + # + @_fromRaw: (obj) -> + {label, property_keys} = obj + + if property_keys.length > 1 + console.warn "Index (on :#{label}) with #{property_keys.length} + property keys encountered: #{property_keys.join ', '}. + node-neo4j v#{lib.version} doesn’t know how to handle these. + Continuing with only the first one." + + [property] = property_keys + + return new Index {label, property} diff --git a/lib-new/Node.coffee b/lib-new/Node.coffee new file mode 100644 index 0000000..7dc1e2e --- /dev/null +++ b/lib-new/Node.coffee @@ -0,0 +1,51 @@ +utils = require './utils' + +module.exports = class Node + + constructor: (opts={}) -> + {@_id, @labels, @properties} = opts + + equals: (other) -> + # TODO: Is this good enough? Often don't want exact equality, e.g. + # nodes' properties may change between queries. + (other instanceof Node) and (@_id is other._id) + + toString: -> + labels = @labels.map (label) -> ":#{label}" + "(#{@_id}#{labels.join ''})" # E.g. (123), (456:Foo), (789:Foo:Bar) + + # + # Accepts the given raw JSON from Neo4j's REST API, and if it represents a + # valid node, creates and returns a Node instance from it. + # If the JSON doesn't represent a valid node, returns null. + # + @_fromRaw: (obj) -> + return null if (not obj) or (typeof obj isnt 'object') + + {data, metadata, self} = obj + + return null if (not self) or (typeof self isnt 'string') or + (not data) or (typeof data isnt 'object') + + # Metadata was only added in Neo4j 2.1.5, so don't *require* it, + # but (a) it makes our job easier, and (b) it's the only way we can get + # labels, so warn the developer if it's missing, but only once. + if metadata + {id, labels} = metadata + else + id = utils.parseId self + labels = null + + if not @_warnedMetadata + @_warnedMetadata = true + console.warn 'It looks like you’re running Neo4j <2.1.5. + Neo4j <2.1.5 didn’t return label metadata to drivers, + so node-neo4j has no way to associate nodes with labels. + Thus, the `labels` property on node-neo4j `Node` instances + will always be null for you. Consider upgrading to fix. =) + http://neo4j.com/release-notes/neo4j-2-1-5/' + + return new Node + _id: id + labels: labels + properties: data diff --git a/lib-new/Relationship.coffee b/lib-new/Relationship.coffee new file mode 100644 index 0000000..fcd60e4 --- /dev/null +++ b/lib-new/Relationship.coffee @@ -0,0 +1,44 @@ +utils = require './utils' + +module.exports = class Relationship + + constructor: (opts={}) -> + {@_id, @type, @properties, @_fromId, @_toId} = opts + + equals: (other) -> + # TODO: Is this good enough? Often don't want exact equality, e.g. + # nodes' properties may change between queries. + (other instanceof Relationship) and (@_id is other._id) + + toString: -> + "-[#{@_id}:#{@type}]-" # E.g. -[123:FOLLOWS]- + + # + # Accepts the given raw JSON from Neo4j's REST API, and if it represents a + # valid relationship, creates and returns a Relationship instance from it. + # If the JSON doesn't represent a valid relationship, returns null. + # + @_fromRaw: (obj) -> + return null if (not obj) or (typeof obj isnt 'object') + + {data, self, type, start, end} = obj + + return null if (not self) or (typeof self isnt 'string') or + (not type) or (typeof type isnt 'string') or + (not start) or (typeof start isnt 'string') or + (not end) or (typeof end isnt 'string') or + (not data) or (typeof data isnt 'object') + + # Relationships also have `metadata`, added in Neo4j 2.1.5, but it + # doesn't provide anything new. (And it doesn't give us from/to ID.) + # We don't want to rely on it, so we don't bother using it at all. + id = utils.parseId self + fromId = utils.parseId start + toId = utils.parseId end + + return new Relationship + _id: id + type: type + properties: data + _fromId: fromId + _toId: toId diff --git a/lib-new/Transaction.coffee b/lib-new/Transaction.coffee new file mode 100644 index 0000000..9e71494 --- /dev/null +++ b/lib-new/Transaction.coffee @@ -0,0 +1,182 @@ +errors = require './errors' +utils = require './utils' + +# This value is used to construct a Date instance, and unfortunately, neither +# Infinity nor Number.MAX_VALUE are valid Date inputs. There's also no simple +# max value for Dates either (http://stackoverflow.com/a/11526569/132978), +# so we arbitrarily do one year ahead. Hopefully this doesn't matter. +FAR_FUTURE_MS = Date.now() + 1000 * 60 * 60 * 24 * 365 + + +# http://neo4j.com/docs/stable/rest-api-transactional.html +module.exports = class Transaction + + constructor: (@_db) -> + @_id = null + @_expires = null + @_pending = false + @_committed = false + @_rolledBack = false + + # Convenience helper for nice getter syntax: + # (NOTE: This does *not* support sibling setters, as we don't need it, + # but it's possible to add support.) + get = (props) => + for name, getter of props + Object.defineProperty @::, name, + configurable: true # For developer-friendliness, e.g. tweaking. + enumerable: true # So these show up in console.log, etc. + get: getter + + # This nice getter syntax uses implicit braces, however. + # coffeelint: disable=no_implicit_braces + + get STATE_OPEN: -> 'open' + get STATE_PENDING: -> 'pending' + get STATE_COMMITTED: -> 'committed' + get STATE_ROLLED_BACK: -> 'rolled back' + get STATE_EXPIRED: -> 'expired' + + get expiresAt: -> + if @_expires + new Date @_expires + else + # This transaction hasn't been created yet, so far future: + new Date FAR_FUTURE_MS + + get expiresIn: -> + if @_expires + @expiresAt - (new Date) + else + # This transaction hasn't been created yet, so far future. + # Unlike for the Date instance above, we can be less arbitrary; + # hopefully it's never a problem to return Infinity here. + Infinity + + get state: -> switch + # Order matters here. + # E.g. a request could have been made just before the expiry time, + # and we won't know the new expiry time until the server responds. + # + # TODO: The server could also receive it just *after* the expiry + # time, which'll cause it to return an unhelpful `UnknownId` error; + # should we handle that edge case in our `cypher` callback below? + # + when @_pending then @STATE_PENDING + when @_committed then @STATE_COMMITTED + when @_rolledBack then @STATE_ROLLED_BACK + when @expiresIn <= 0 then @STATE_EXPIRED + else @STATE_OPEN + + # For the above getters. + # coffeelint: enable=no_implicit_braces + + # NOTE: CoffeeLint currently false positives on this next line. + # https://github.com/clutchski/coffeelint/issues/458 + # coffeelint: disable=no_implicit_braces + cypher: (opts={}, cb) -> + # coffeelint: enable=no_implicit_braces + + # Check predictable error cases to provide better messaging sooner. + # All of these are `ClientErrors` within the `Transaction` category. + # http://neo4j.com/docs/stable/status-codes.html#_status_codes + errMsg = switch @state + when @STATE_PENDING + # This would otherwise throw a `ConcurrentRequest` error. + 'A request within this transaction is currently in progress. + Concurrent requests within a transaction are not allowed.' + when @STATE_EXPIRED + # This would otherwise throw an `UnknownId` error. + 'This transaction has expired. + You can get the expiration time of a transaction through its + `expiresAt` (Date) and `expiresIn` (ms) properties. + To prevent a transaction from expiring, execute any action + or call `renew` before the transaction expires.' + when @STATE_COMMITTED + # This would otherwise throw an `UnknownId` error. + 'This transaction has been committed. + Transactions cannot be reused; begin a new one instead.' + when @STATE_ROLLED_BACK + # This would otherwise throw an `UnknownId` error. + 'This transaction has been rolled back. + Transactions get automatically rolled back on any + DatabaseErrors, as well as any errors during a commit. + That includes auto-commit queries (`{commit: true}`). + Transactions cannot be reused; begin a new one instead.' + + if errMsg + # TODO: Should we callback this error instead? (And if so, should we + # `process.nextTick` that call?) + # I *think* that these cases are more likely to be code bugs than + # legitimate runtime errors, so the benefit of throwing sync'ly is + # fail-fast behavior, and more helpful stack traces. + throw new errors.ClientError errMsg + + # The only state we should be in at this point is 'open'. + @_pending = true + @_db.cypher opts, (err, results) => + @_pending = false + + # If this transaction still exists, no state changes for us: + if @_id + return cb err, results + + # Otherwise, this transaction was destroyed -- either committed or + # rolled back -- so update our state accordingly. + # Much easier to derive whether committed than whether rolled back, + # because commits can only happen when explicitly requested. + if opts.commit and not err + @_committed = true + else + @_rolledBack = true + + cb err, results + , @ + + commit: (cb) -> + if not cb + throw new Error 'No callback provided!' + + @cypher {commit: true}, cb + + rollback: (cb) -> + @cypher {rollback: true}, cb + + renew: (cb) -> + @cypher {}, cb + + # + # Updates this Transaction instance with data from the given transactional + # endpoint response. + # + _updateFromResponse: (resp) -> + if not resp + throw new Error 'Unexpected: no transactional response!' + + {body, headers, statusCode} = resp + {transaction} = body + + if not transaction + # This transaction has been destroyed (either committed or rolled + # back). Our state will get updated in the `cypher` callback above. + @_id = @_expires = null + return + + # Otherwise, this transaction exists. + # The returned object always includes an updated expiry time... + @_expires = new Date transaction.expires + + # ...but only includes the URL (from which we can parse its ID) + # the first time, via a Location header for a 201 Created response. + # We can short-circuit if we already have our ID. + return if @_id + + if statusCode isnt 201 + throw new Error 'Unexpected: transaction returned by Neo4j, + but it was never 201 Created, so we have no ID!' + + if not transactionURL = headers['location'] + throw new Error 'Unexpected: transaction response is 201 Created, + but with no Location header!' + + @_id = utils.parseId transactionURL diff --git a/lib-new/errors.coffee b/lib-new/errors.coffee new file mode 100644 index 0000000..516d2dd --- /dev/null +++ b/lib-new/errors.coffee @@ -0,0 +1,103 @@ +$ = require 'underscore' +assert = require 'assert' +http = require 'http' + +class @Error extends Error + + constructor: (@message='Unknown error', @neo4j={}) -> + @name = 'neo4j.' + @constructor.name + Error.captureStackTrace @, @constructor + + # + # Accepts the given HTTP client response, and if it represents an error, + # creates and returns the appropriate Error instance from it. + # If the response doesn't represent an error, returns null. + # + @_fromResponse: (resp) -> + {body, headers, statusCode} = resp + + return null if statusCode < 400 + + # If this is a Neo4j v2-style error response, prefer that: + if body?.errors?.length + # TODO: Is it possible to get back more than one error? + # If so, is it fine for us to just use the first one? + [error] = body.errors + return @_fromObject error + + # TODO: Do some status codes (or perhaps inner `exception` names) + # signify Transient errors rather than Database ones? + ErrorType = if statusCode >= 500 then 'Database' else 'Client' + ErrorClass = exports["#{ErrorType}Error"] + + message = "#{statusCode} " + logBody = statusCode >= 500 # TODO: Config to always log body? + + if body?.exception + message += "[#{body.exception}] #{body.message or '(no message)'}" + else + statusText = http.STATUS_CODES[statusCode] # E.g. "Not Found" + reqText = "#{resp.req.method} #{resp.req.path}" + message += "#{statusText} response for #{reqText}" + logBody = true # always log body if non-error returned + + if logBody and body? + message += ": #{JSON.stringify body, null, 4}" + + new ErrorClass message, body + + # + # Accepts the given (Neo4j v2) error object, and creates and returns the + # appropriate Error instance for it. + # + @_fromObject: (obj) -> + # NOTE: Neo4j 2.2 seems to return both `stackTrace` and `stacktrace`. + # https://github.com/neo4j/neo4j/issues/4145#issuecomment-78203290 + # Normalizing to consistent `stackTrace` before we parse below! + if obj.stacktrace? and not obj.stackTrace? + obj.stackTrace = obj.stacktrace + delete obj.stacktrace + + # http://neo4j.com/docs/stable/rest-api-transactional.html#rest-api-handling-errors + # http://neo4j.com/docs/stable/status-codes.html + {code, message, stackTrace} = obj + [neo, classification, category, title] = code.split '.' + + ErrorClass = exports[classification] # e.g. DatabaseError + + # Prefix all messages with the full semantic code, for at-a-glance-ness: + fullMessage = "[#{code}] " + + # If this is a database error with a Java stack trace from Neo4j, + # include that stack, for bug reporting to the Neo4j team. + # Also include the stack if there's no summary message. + # TODO: Should we make it configurable to always include it? + # NOTE: The stack seems to be returned as a string, not an array. + if stackTrace and (classification is 'DatabaseError' or not message) + # It seems that this stack trace includes the summary message, + # but checking just in case it doesn't, and adding it if so. + if message and (stackTrace.indexOf message) is -1 + stackTrace = "#{message}: #{stackTrace}" + + # Stack traces can include "Caused by" lines which aren't indented, + # and indented lines use tabs. Normalize to 4 spaces, and indent + # everything one extra level, to differentiate from Node.js lines. + stackTrace = stackTrace + .replace /\t/g, ' ' + .replace /\n/g, '\n ' + + fullMessage += stackTrace + + # Otherwise, e.g. for client errors, omit any stack; just the message: + else + fullMessage += message + + new ErrorClass fullMessage, obj + + # TODO: Helper to rethrow native/inner errors? Not sure if we need one. + +class @ClientError extends @Error + +class @DatabaseError extends @Error + +class @TransientError extends @Error diff --git a/lib-new/exports.coffee b/lib-new/exports.coffee new file mode 100644 index 0000000..634dd0a --- /dev/null +++ b/lib-new/exports.coffee @@ -0,0 +1,11 @@ +$ = require 'underscore' + +$(exports).extend + GraphDatabase: require './GraphDatabase' + Node: require './Node' + Relationship: require './Relationship' + Transaction: require './Transaction' + Index: require './Index' + Constraint: require './Constraint' + +$(exports).extend require './errors' diff --git a/lib-new/utils.coffee b/lib-new/utils.coffee new file mode 100644 index 0000000..23e6dd6 --- /dev/null +++ b/lib-new/utils.coffee @@ -0,0 +1,15 @@ + +# +# Parses and returns the top-level native Neo4j ID out of the given Neo4j URL, +# or null if no top-level ID could be matched. +# +# Works with any type of object exposed by Neo4j at the root of the service API, +# e.g. nodes, relationships, even transactions. +# +@parseId = (url) -> + # NOTE: Neo4j 2.1.7 shipped a bug with hypermedia links returned from the + # transactional endpoint, so we have to account for that: + # https://github.com/neo4j/neo4j/issues/4076 + match = url.match /// (?:commit|/)db/data/\w+/(\d+)($|/) /// + return null if not match + return parseInt match[1], 10 diff --git a/package.json b/package.json index dacaab7..07aa6eb 100644 --- a/package.json +++ b/package.json @@ -1,21 +1,21 @@ { "name": "neo4j", "description": "Neo4j driver (REST API client) for Node.js", - "version": "2.0.0-alpha1", + "version": "2.0.0-RC2", "author": "Aseem Kishore ", "contributors": [ "Daniel Gasienica ", "Sergio Haro " ], - "main": "./lib-old", + "main": "./lib-new/exports", "dependencies": { - "http-status": "^0.1.0", - "request": "^2.27.0" + "request": "^2.27.0", + "underscore": "1.7.x" }, "devDependencies": { "chai": "^1.9.2", - "codo": "^2.0.9", "coffee-script": "1.8.x", + "coffeelint": "^1.9.7", "mocha": "^2.0.1", "streamline": "^0.10.16" }, @@ -23,19 +23,19 @@ "node": ">= 0.10" }, "scripts": { - "build": "coffee -m -c lib-old/ && _coffee -m --standalone -c lib-old/", - "clean": "rm -f lib-old/*.{js,map}", - "codo": "codo && codo --server", + "build": "coffee -m -c lib-new/", + "clean": "rm -f lib-new/*.{js,map}", + "lint": "git ls-files | grep coffee$ | grep -v '\\-old/' | xargs coffeelint", "prepublish": "npm run build", "postpublish": "npm run clean", - "test": "mocha" + "test": "mocha test-new" }, "keywords": [ "neo4j", "graph", "database", "driver", "client", "cypher" ], "license": "Apache-2.0", - "repository" : { - "type" : "git", - "url" : "https://github.com/thingdom/node-neo4j.git" + "repository": { + "type": "git", + "url": "https://github.com/thingdom/node-neo4j.git" } } diff --git a/test-new/_auth._coffee b/test-new/_auth._coffee new file mode 100644 index 0000000..5d49b22 --- /dev/null +++ b/test-new/_auth._coffee @@ -0,0 +1,136 @@ +# +# Tests for basic auth management, e.g. the ability to change user passwords. +# +# IMPORTANT: Against a fresh Neo4j 2.2 database (which requires both auth and +# an initial password change by default), this test must be the first to run, +# in order for other tests not to fail. Hence the underscore-prefixed filename. +# (The original password is restored at the end of this suite.) +# +# NOTE: Since auth (a) can be disabled, and (b) isn't supported by Neo4j <2.2, +# this suite first checks if auth is enabled, and *only* runs if it is. +# If auth is disabled or not present, every test here will be skipped. +# + +$ = require 'underscore' +{expect} = require 'chai' +fixtures = require './fixtures' +helpers = require './util/helpers' +neo4j = require '../' + + +## SHARED STATE + +{DB} = fixtures + +ORIGINAL_PASSWORD = DB.auth?.password +RANDOM_PASSWORD = helpers.getRandomStr() + +SUITE = null + + +## HELPERS + +disable = (reason) -> + console.warn "#{reason}; not running auth tests." + + # HACK: Perhaps relying on Mocha's internals to achieve skip all: + for test, i in SUITE.tests + continue if i is 0 # This is our "check" test + test.pending = true + + # TODO: It'd be nice if we could support bailing on *all* suites, + # in case of an auth error, e.g. bad credentials. + + +## TESTS + +describe 'Auth', -> + + SUITE = @ + + it '(check if auth is enabled)', (_) -> + if not ORIGINAL_PASSWORD + return disable 'Auth creds unspecified' + + # Querying user status (what this check method does) fails both when + # auth is unavailable (Neo4j 2.1-) and when it's disabled (Neo4j 2.2+). + try + DB.checkPasswordChangeNeeded _ + catch err + if err instanceof neo4j.ClientError and err.message.match /^404 / + disable 'Auth disabled or unavailable' + else + disable 'Error checking auth' + throw err + + it 'should fail when auth is required but not set', (done) -> + db = new neo4j.GraphDatabase + url: DB.url + auth: {} # Explicitly clears auth + + # NOTE: Explicitly not using `db.checkPasswordChangeNeeded` since that + # rejects calls when no auth is set. + db.http '/db/data/', (err, data) -> + expect(err).to.exist() + helpers.expectError err, 'ClientError', 'Security', + 'AuthorizationFailed', 'No authorization header supplied.' + expect(data).to.not.exist() + done() + + it 'should fail when auth is incorrect (username)', (done) -> + db = new neo4j.GraphDatabase + url: DB.url + auth: $(DB.auth).clone() + + db.auth.username = RANDOM_PASSWORD + + db.checkPasswordChangeNeeded (err, bool) -> + expect(err).to.exist() + helpers.expectError err, 'ClientError', 'Security', + 'AuthorizationFailed', 'Invalid username or password.' + expect(bool).to.not.exist() + done() + + it 'should fail when auth is incorrect (password)', (done) -> + db = new neo4j.GraphDatabase + url: DB.url + auth: $(DB.auth).clone() + + db.auth.password = RANDOM_PASSWORD + + db.checkPasswordChangeNeeded (err, bool) -> + expect(err).to.exist() + helpers.expectError err, 'ClientError', 'Security', + 'AuthorizationFailed', 'Invalid username or password.' + expect(bool).to.not.exist() + done() + + it 'should support checking whether a password change is needed', (_) -> + needed = DB.checkPasswordChangeNeeded _ + expect(needed).to.be.a 'boolean' + + it 'should support changing the current user’s password', (_) -> + DB.changePassword RANDOM_PASSWORD, _ + + it 'should reject empty and null new passwords', -> + cb = -> throw new Error 'Callback shouldn’t have been called!' + + for fn in [ + -> DB.changePassword null, cb + -> DB.changePassword '', cb + -> DB.changePassword {}, cb + -> DB.changePassword {password: null}, cb + -> DB.changePassword {password: ''}, cb + ] + expect(fn).to.throw TypeError, /Password required/ + + it 'should automatically update state on password changes', (_) -> + expect(DB.auth.password).to.equal RANDOM_PASSWORD + + # Verify with another password change needed check: + needed = DB.checkPasswordChangeNeeded _ + expect(needed).to.equal false + + it '(change password back)', (_) -> + DB.changePassword ORIGINAL_PASSWORD, _ + expect(DB.auth.password).to.equal ORIGINAL_PASSWORD diff --git a/test-new/constraints._coffee b/test-new/constraints._coffee new file mode 100644 index 0000000..f5cdbcf --- /dev/null +++ b/test-new/constraints._coffee @@ -0,0 +1,256 @@ +# +# Tests for constraints, e.g. creating, retrieivng, and dropping constraints. +# In the process, also tests that constraints actually have an effect. +# + +{expect} = require 'chai' +fixtures = require './fixtures' +helpers = require './util/helpers' +neo4j = require '../' + + +## SHARED STATE + +{DB, TEST_LABEL} = fixtures + +[TEST_NODE_A, TEST_REL, TEST_NODE_B] = [] + +# Important: we generate a random prop to guarantee no same constraint yet. +# We set the value to the node's ID, to ensure uniqueness. +TEST_PROP = "constraint_#{helpers.getRandomStr()}" + +# These are the constraints that exist before this test suite runs... +ORIG_CONSTRAINTS_ALL = null # ...Across all labels +ORIG_CONSTRAINTS_LABEL = null # ...On our TEST_LABEL + +# And this is the constraint we create: +TEST_CONSTRAINT = null + + +## HELPERS + +expectConstraint = (constraint, label, property) -> + expect(constraint).to.be.an.instanceOf neo4j.Constraint + expect(constraint.label).to.equal label if label + expect(constraint.property).to.equal property if property + +expectConstraints = (constraints, label) -> + expect(constraints).to.be.an 'array' + for constraint in constraints + expectConstraint constraint, label + +violateConstraint = (_) -> + # Do this in a transaction, so that we don't actually persist: + tx = DB.beginTransaction() + + try + tx.cypher + query: """ + START n = node({idB}) + SET n.#{TEST_PROP} = {idA} + """ + params: + idA: TEST_NODE_A._id + idB: TEST_NODE_B._id + , _ + + # Not technically needed, but prevent Neo4j from waiting up to a minute for + # the transaction to expire in case of any errors: + finally + # Neo4j 2.2.6+ automatically roll transactions back in case of client + # errors too, so guard against that. + unless tx.state is tx.STATE_ROLLED_BACK + tx.rollback _ + + +## TESTS + +describe 'Constraints', -> + + # IMPORTANT: Mocha requires all test steps to be at the same nesting level + # for them to all execute in order. Hence "setup" and "teardown" wrappers. + + describe '(setup)', -> + + it '(create test nodes)', (_) -> + [TEST_NODE_A, TEST_REL, TEST_NODE_B] = + fixtures.createTestGraph module, 2, _ + + it '(set test properties)', (_) -> + DB.cypher + query: """ + START n = node({ids}) + SET n.#{TEST_PROP} = ID(n) + """ + params: + ids: [TEST_NODE_A._id, TEST_NODE_B._id] + , _ + + # Update our local instances too: + TEST_NODE_A.properties[TEST_PROP] = TEST_NODE_A._id + TEST_NODE_B.properties[TEST_PROP] = TEST_NODE_B._id + + + describe '(before constraint created)', -> + + it 'should support listing all constraints', (_) -> + constraints = DB.getConstraints _ + expectConstraints constraints + + ORIG_CONSTRAINTS_ALL = constraints + + it 'should support listing constraints for a particular label', (_) -> + constraints = DB.getConstraints TEST_LABEL, _ + expectConstraints constraints, TEST_LABEL + + ORIG_CONSTRAINTS_LABEL = constraints + + it 'should support querying for specific constraint', (_) -> + exists = DB.hasConstraint + label: TEST_LABEL + property: TEST_PROP + , _ + + expect(exists).to.equal false + + it '(verify constraint doesn’t exist yet)', (_) -> + # This shouldn't throw an error: + violateConstraint _ + + it 'should support creating constraint', (_) -> + # TODO: This can randomly take a *very* long time. + # (Why? We're using a new and unique property. Maybe we should be + # using a new and unique label too?) + # So for now, we 10x the Mocha timeout. + @timeout 10 * @timeout() + console.log 'Creating constraint. This could take a while...' + + constraint = DB.createConstraint + label: TEST_LABEL + property: TEST_PROP + , _ + + expectConstraint constraint, TEST_LABEL, TEST_PROP + + TEST_CONSTRAINT = constraint + + + describe '(after constraint created)', -> + + it '(verify by re-listing all constraints)', (_) -> + constraints = DB.getConstraints _ + expect(constraints).to.have.length ORIG_CONSTRAINTS_ALL.length + 1 + expect(constraints).to.contain TEST_CONSTRAINT + + it '(verify by re-listing constraints for test label)', (_) -> + constraints = DB.getConstraints TEST_LABEL, _ + expect(constraints).to.have.length ORIG_CONSTRAINTS_LABEL.length + 1 + expect(constraints).to.contain TEST_CONSTRAINT + + it '(verify by re-querying specific test constraint)', (_) -> + exists = DB.hasConstraint + label: TEST_LABEL + property: TEST_PROP + , _ + + expect(exists).to.equal true + + it '(verify with test query)', (done) -> + violateConstraint (err) -> + expect(err).to.exist() + + helpers.expectError err, + 'ClientError', 'Schema', 'ConstraintViolation', + "Node #{TEST_NODE_A._id} already exists + with label #{TEST_LABEL} + and property \"#{TEST_PROP}\"=[#{TEST_NODE_A._id}]" + + done() + + it 'should be idempotent on repeat creates of constraint', (_) -> + constraint = DB.createConstraint + label: TEST_LABEL + property: TEST_PROP + , _ + + expect(constraint).to.not.exist() + + it 'should support dropping constraint', (_) -> + dropped = DB.dropConstraint + label: TEST_LABEL + property: TEST_PROP + , _ + + expect(dropped).to.equal true + + + describe '(after constraint dropped)', -> + + it '(verify by re-listing all constraints)', (_) -> + constraints = DB.getConstraints _ + expect(constraints).to.eql ORIG_CONSTRAINTS_ALL + + it '(verify by re-listing constraints for test label)', (_) -> + constraints = DB.getConstraints TEST_LABEL, _ + expect(constraints).to.eql ORIG_CONSTRAINTS_LABEL + + it '(verify by re-querying specific test constraint)', (_) -> + exists = DB.hasConstraint + label: TEST_LABEL + property: TEST_PROP + , _ + + expect(exists).to.equal false + + it 'should be idempotent on repeat drops of constraint', (_) -> + dropped = DB.dropConstraint + label: TEST_LABEL + property: TEST_PROP + , _ + + expect(dropped).to.equal false + + + describe '(misc)', -> + + fail = -> throw new Error 'Callback should not have been called' + + it 'should require both label and property + to query specific constraint', -> + for fn in [ + -> DB.hasConstraint null, fail + -> DB.hasConstraint '', fail + -> DB.hasConstraint {}, fail + -> DB.hasConstraint TEST_LABEL, fail + -> DB.hasConstraint {label: TEST_LABEL}, fail + -> DB.hasConstraint {property: TEST_PROP}, fail + ] + expect(fn).to.throw TypeError, /label and property required/i + + it 'should require both label and property to create constraint', -> + for fn in [ + -> DB.createConstraint null, fail + -> DB.createConstraint '', fail + -> DB.createConstraint {}, fail + -> DB.createConstraint TEST_LABEL, fail + -> DB.createConstraint {label: TEST_LABEL}, fail + -> DB.createConstraint {property: TEST_PROP}, fail + ] + expect(fn).to.throw TypeError, /label and property required/i + + it 'should require both label and property to drop constraint', -> + for fn in [ + -> DB.dropConstraint null, fail + -> DB.dropConstraint '', fail + -> DB.dropConstraint {}, fail + -> DB.dropConstraint TEST_LABEL, fail + -> DB.dropConstraint {label: TEST_LABEL}, fail + -> DB.dropConstraint {property: TEST_PROP}, fail + ] + expect(fn).to.throw TypeError, /label and property required/i + + + describe '(teardown)', -> + + it '(delete test nodes)', (_) -> + fixtures.deleteTestGraph module, _ diff --git a/test-new/constructor._coffee b/test-new/constructor._coffee new file mode 100644 index 0000000..b36d29f --- /dev/null +++ b/test-new/constructor._coffee @@ -0,0 +1,164 @@ +# +# Tests for the GraphDatabase constructor, e.g. its options and overloads. +# + +$ = require 'underscore' +{expect} = require 'chai' +{GraphDatabase} = require '../' +helpers = require './util/helpers' + + +## CONSTANTS + +URL = 'https://example.com:1234' +PROXY = 'https://some.proxy:5678' +HEADERS = + 'x-foo': 'bar-baz' + 'x-lorem': 'ipsum' + # TODO: Test overlap with default headers? + # TODO: Test custom User-Agent behavior, or blacklist X-Stream? + +USERNAME = 'alice' +PASSWORD = 'p4ssw0rd' + + +## HELPERS + +# +# Asserts that the given object is an instance of GraphDatabase, +# pointing to the given URL, optionally using the given proxy URL. +# +expectDatabase = (db, url, proxy) -> + expect(db).to.be.an.instanceOf GraphDatabase + expect(db.url).to.equal url + expect(db.proxy).to.equal proxy + +# +# Asserts that the given GraphDatabase instance has its `headers` property set +# to the union of the given headers and the default GraphDatabase `headers`, +# with the given headers taking precedence. +# +# TODO: If we special-case User-Agent or blacklist X-Stream, update here. +# +expectHeaders = (db, headers) -> + expect(db.headers).to.be.an 'object' + + defaultHeaders = GraphDatabase::headers + defaultKeys = Object.keys defaultHeaders + givenKeys = Object.keys headers + expectedKeys = $.union defaultKeys, givenKeys # This de-dupes too. + + # This is an exact check, i.e. *only* these keys: + expect(db.headers).to.have.keys expectedKeys + + for key, val of db.headers + expect(val).to.equal headers[key] or defaultHeaders[key] + +expectAuth = (db, username, password) -> + expect(db.auth).to.eql {username, password} + +expectNoAuth = (db) -> + expect(db.auth).to.not.exist() + + +## TESTS + +describe 'GraphDatabase::constructor', -> + + it 'should support full options', -> + db = new GraphDatabase + url: URL + proxy: PROXY + headers: HEADERS + + expectDatabase db, URL, PROXY + expectHeaders db, HEADERS + expectNoAuth db + + it 'should support just URL string', -> + db = new GraphDatabase URL + + expectDatabase db, URL + expectHeaders db, {} + expectNoAuth db + + it 'should throw if no URL given', -> + fn = -> new GraphDatabase() + expect(fn).to.throw TypeError, /URL to Neo4j required/ + + # Also try giving an options argument, just with no URL: + fn = -> new GraphDatabase {proxy: PROXY} + expect(fn).to.throw TypeError, /URL to Neo4j required/ + + it 'should support and parse auth in URL', -> + url = "https://#{USERNAME}:#{PASSWORD}@auth.test:9876" + db = new GraphDatabase url + + expectDatabase db, url + expectAuth db, USERNAME, PASSWORD + + it 'should support and parse auth as separate string option', -> + db = new GraphDatabase + url: URL + auth: "#{USERNAME}:#{PASSWORD}" + + expectDatabase db, URL + expectAuth db, USERNAME, PASSWORD + + it 'should support and parse auth as separate object option', -> + db = new GraphDatabase + url: URL + auth: + username: USERNAME + password: PASSWORD + + expectDatabase db, URL + expectAuth db, USERNAME, PASSWORD + + it 'should prefer separate auth option over auth in the URL + (and should clear auth in URL then)', -> + host = 'auth.test:9876' + wrong1 = helpers.getRandomStr() + wrong2 = helpers.getRandomStr() + + db = new GraphDatabase + url: "https://#{wrong1}:#{wrong2}@#{host}" + auth: "#{USERNAME}:#{PASSWORD}" + + # NOTE: The constructor adds a trailing slash, but that's okay. + expectDatabase db, "https://#{host}/" + expectAuth db, USERNAME, PASSWORD + + it 'should support clearing auth via empty string option', -> + host = 'auth.test:9876' + url = "https://#{USERNAME}:#{PASSWORD}@#{host}" + + db = new GraphDatabase + url: url + auth: '' + + # NOTE: The constructor adds a trailing slash, but that's okay. + expectDatabase db, "https://#{host}/" + expectNoAuth db + + it 'should support clearing auth via empty object option', -> + host = 'auth.test:9876' + url = "https://#{USERNAME}:#{PASSWORD}@#{host}" + + db = new GraphDatabase + url: url + auth: {} + + # NOTE: The constructor adds a trailing slash, but that's okay. + expectDatabase db, "https://#{host}/" + expectNoAuth db + + it 'should be robust to colons in the password with string option', -> + password = "#{PASSWORD}:#{PASSWORD}:#{PASSWORD}" + + db = new GraphDatabase + url: URL + auth: "#{USERNAME}:#{password}" + + expectDatabase db, URL + expectAuth db, USERNAME, password diff --git a/test-new/cypher._coffee b/test-new/cypher._coffee new file mode 100644 index 0000000..45feacc --- /dev/null +++ b/test-new/cypher._coffee @@ -0,0 +1,280 @@ +# +# Tests for the GraphDatabase `cypher` method, e.g. the ability to make queries, +# parametrize them, have responses be auto-parsed for nodes & rels, etc. +# + +{expect} = require 'chai' +fixtures = require './fixtures' +helpers = require './util/helpers' +neo4j = require '../' + + +## SHARED STATE + +{DB} = fixtures + +[TEST_NODE_A, TEST_NODE_B, TEST_REL] = [] + + +## TESTS + +describe 'GraphDatabase::cypher', -> + + it 'should support simple queries and results', (_) -> + results = DB.cypher 'RETURN "bar" AS foo', _ + + expect(results).to.be.an 'array' + expect(results).to.have.length 1 + + [result] = results + + expect(result).to.be.an 'object' + expect(result).to.contain.keys 'foo' # this is an exact/"only" check + expect(result.foo).to.equal 'bar' + + it 'should support simple parameters', (_) -> + [result] = DB.cypher + query: 'RETURN {foo} AS foo' + params: {foo: 'bar'} + , _ + + expect(result).to.be.an 'object' + expect(result.foo).to.equal 'bar' + + it 'should support complex queries, params, and results', (_) -> + results = DB.cypher + query: ''' + UNWIND {nodes} AS node + WITH node + ORDER BY node.id + LIMIT {count} + RETURN node.id, node.name, {count} + ''' + params: + count: 3 + nodes: [ + {id: 2, name: 'Bob'} + {id: 4, name: 'Dave'} + {id: 1, name: 'Alice'} + {id: 3, name: 'Carol'} + ] + , _ + + expect(results).to.eql [ + 'node.id': 1 + 'node.name': 'Alice' + '{count}': 3 + , + 'node.id': 2 + 'node.name': 'Bob' + '{count}': 3 + , + 'node.id': 3 + 'node.name': 'Carol' + '{count}': 3 + ] + + it 'should support queries that return nothing', (_) -> + results = DB.cypher + query: 'MATCH (n:FooBarBazThisLabelDoesntExist) RETURN n' + params: {unused: 'param'} + , _ + + expect(results).to.be.empty() + + it 'should reject empty/missing queries', -> + fail = -> throw new Error 'Callback should not have been called' + fn1 = -> DB.cypher '', fail + fn2 = -> DB.cypher {}, fail + expect(fn1).to.throw TypeError, /query/i + expect(fn2).to.throw TypeError, /query/i + + it 'should properly parse and throw Neo4j errors', (done) -> + DB.cypher 'RETURN {foo}', (err, results) -> + expect(err).to.exist() + helpers.expectError err, 'ClientError', 'Statement', + 'ParameterMissing', 'Expected a parameter named foo' + + # Whether `results` are returned or not depends on the error; + # Neo4j will return an array if the query could be executed, + # and then it'll return whatever results it could manage to get + # before the error. In this case, the query began execution, + # so we expect an array, but no actual results. + expect(results).to.be.an 'array' + expect(results).to.be.empty() + + done() + + it 'should properly return null result on syntax errors', (done) -> + DB.cypher '(syntax error)', (err, results) -> + expect(err).to.exist() + + # Simplified error checking, since the message is complex: + expect(err).to.be.an.instanceOf neo4j.ClientError + expect(err.neo4j).to.be.an 'object' + expect(err.neo4j.code).to.equal \ + 'Neo.ClientError.Statement.InvalidSyntax' + + # Unlike the previous test case, since Neo4j could not be + # executed, no results should have been returned at all: + expect(results).to.not.exist() + + done() + + it '(create test graph)', (_) -> + [TEST_NODE_A, TEST_REL, TEST_NODE_B] = + fixtures.createTestGraph module, 2, _ + + it 'should properly parse nodes & relationships', (_) -> + # We do a complex return to test nested/wrapped objects too. + # NOTE: However, returning an array changes the order of the returned + # results, no longer the deterministic order of [a, b, r]. + # We overcome this by explicitly indexing and ordering. + results = DB.cypher + query: ''' + START a = node({idA}) + MATCH (a) -[r]-> (b) + WITH [ + {i: 0, elmt: a}, + {i: 1, elmt: b}, + {i: 2, elmt: r} + ] AS array + UNWIND array AS obj + RETURN obj.i AS i, [{ + inner: obj.elmt + }] AS outer + ORDER BY i + ''' + params: + idA: TEST_NODE_A._id + , _ + + expect(results).to.eql [ + i: 0 + outer: [ + inner: TEST_NODE_A + ] + , + i: 1 + outer: [ + inner: TEST_NODE_B + ] + , + i: 2 + outer: [ + inner: TEST_REL + ] + ] + + # But also test that the returned objects are proper instances: + expect(results[0].outer[0].inner).to.be.an.instanceOf neo4j.Node + expect(results[1].outer[0].inner).to.be.an.instanceOf neo4j.Node + expect(results[2].outer[0].inner).to.be.an.instanceOf neo4j.Relationship + + it 'should not parse nodes & relationships if lean', (_) -> + results = DB.cypher + query: ''' + START a = node({idA}) + MATCH (a) -[r]-> (b) + RETURN a, b, r + ''' + params: + idA: TEST_NODE_A._id + lean: true + , _ + + expect(results).to.eql [ + a: TEST_NODE_A.properties + b: TEST_NODE_B.properties + r: TEST_REL.properties + ] + + it 'should support simple batching', (_) -> + results = DB.cypher [ + query: ''' + START a = node({idA}) + RETURN a + ''' + params: + idA: TEST_NODE_A._id + , + query: ''' + START b = node({idB}) + RETURN b + ''' + params: + idB: TEST_NODE_B._id + , + query: ''' + START r = rel({idR}) + RETURN r + ''' + params: + idR: TEST_REL._id + ], _ + + expect(results).to.be.an 'array' + expect(results).to.have.length 3 + + [resultsA, resultsB, resultsR] = results + + expect(resultsA).to.eql [ + a: TEST_NODE_A + ] + + expect(resultsB).to.eql [ + b: TEST_NODE_B + ] + + expect(resultsR).to.eql [ + r: TEST_REL + ] + + it 'should handle complex batching with errors', (done) -> + DB.cypher + queries: [ + query: ''' + START a = node({idA}) + RETURN a + ''' + params: + idA: TEST_NODE_A._id + lean: true + , + 'RETURN {foo}' + , + query: ''' + START r = rel({idR}) + RETURN r + ''' + params: + idR: TEST_REL._id + ] + , (err, results) -> + expect(err).to.exist() + helpers.expectError err, 'ClientError', 'Statement', + 'ParameterMissing', 'Expected a parameter named foo' + + # NOTE: With batching, we *do* return any results that we + # received before the error, in case of an open transaction. + # This means that we'll always return an array here, and it'll + # have just as many elements as queries that returned an array + # before the error. In this case, a ParameterMissing error in + # the second query means the second array *was* returned (since + # Neo4j could begin executing the query; see note in the first + # error handling test case in this suite), so two results. + expect(results).to.be.an 'array' + expect(results).to.have.length 2 + + [resultsA, resultsB] = results + expect(resultsA).to.eql [ + a: TEST_NODE_A.properties + ] + expect(resultsB).to.be.empty() + + done() + + it 'should support streaming (TODO)' + + it '(delete test graph)', (_) -> + fixtures.deleteTestGraph module, _ diff --git a/test-new/fixtures/fake.json b/test-new/fixtures/fake.json new file mode 100644 index 0000000..8014f43 --- /dev/null +++ b/test-new/fixtures/fake.json @@ -0,0 +1,7 @@ +{ + "boolean": true, + "number": 1234, + "string": "foo bar baz", + "array": ["foo", "bar", "baz"], + "object": {"foo": {"bar": "baz"}} +} diff --git a/test-new/fixtures/index._coffee b/test-new/fixtures/index._coffee new file mode 100644 index 0000000..f239c25 --- /dev/null +++ b/test-new/fixtures/index._coffee @@ -0,0 +1,173 @@ +# +# NOTE: This file is within a directory named `fixtures`, rather than a file +# named `fixtures._coffee`, in order to not have Mocha treat it like a test. +# + +$ = require 'underscore' +{expect} = require 'chai' +helpers = require '../util/helpers' +neo4j = require '../../' + +exports.DB = new neo4j.GraphDatabase + # Support specifying database info via environment variables, + # but assume Neo4j installation defaults. + url: process.env.NEO4J_URL or 'http://neo4j:neo4j@localhost:7474' + auth: process.env.NEO4J_AUTH + +# We fill these in, and cache them, the first time tests request them: +exports.DB_VERSION_NUM = null +exports.DB_VERSION_STR = null + +exports.TEST_LABEL = 'Test' +exports.TEST_REL_TYPE = 'TEST' + +# +# Queries the Neo4j version of the database we're currently testing against, +# if it's not already known. +# Doesn't return anything; instead, `DB_VERSION_*` will be set after this. +# +exports.queryDbVersion = (_) -> + return if exports.DB_VERSION_NUM + + info = exports.DB.http + method: 'GET' + path: '/db/data/' + , _ + + exports.DB_VERSION_STR = info.neo4j_version or '(version unknown)' + exports.DB_VERSION_NUM = parseFloat exports.DB_VERSION_STR, 10 + + if exports.DB_VERSION_NUM < 2 + throw new Error '*** node-neo4j v2 supports Neo4j v2+ only, + and you’re running Neo4j v1. These tests will fail! ***' + +# +# Creates and returns a property bag (dictionary) with unique, random test data +# for the given test suite (pass the suite's Node `module`). +# +exports.createTestProperties = (suite) -> + suite: suite.filename + rand: helpers.getRandomStr() + +# +# Creates and returns a new Node instance with unique, random test data for the +# given test suite (pass the suite's Node `module`). +# +# NOTE: This method does *not* persist the node. To that end, the returned +# instance *won't* have an `_id` property; you should set it if you persist it. +# +# This method is async because it queries Neo4j's version if we haven't already, +# and strips label metadata if we're running against Neo4j <2.1.5, which didn't +# return label metadata to drivers. +# +exports.createTestNode = (suite, _) -> + node = new neo4j.Node + labels: [exports.TEST_LABEL] + properties: exports.createTestProperties suite + + exports.queryDbVersion _ + + if exports.DB_VERSION_STR < '2.1.5' + node.labels = null + + node + +# +# Creates and returns a new Relationship instance with unique, random test data +# for the given test suite (pass the suite's Node `module`). +# +# NOTE: This method does *not* persist the relationship. To that end, the +# returned instance *won't* have its `_id`, `_fromId` or `_toId` properties set; +# you should set those if you persist this relationship. +# +exports.createTestRelationship = (suite) -> + new neo4j.Relationship + type: exports.TEST_REL_TYPE + properties: exports.createTestProperties suite + +# +# Executes a Cypher query to create and persist a test graph with the given +# number of nodes, connected in a chain. +# +# TODO: Support other types of graphs, e.g. networks, fan-outs, etc.? +# +# The nodes are identified by the filename of the given test suite (pass the +# suite's Node `module`). +# +# Returns an array of Node and Relationship instances for the created graph, +# in chain order, e.g. [node, rel, node]. +# +exports.createTestGraph = (suite, numNodes, _) -> + expect(numNodes).to.be.at.least 1 + numRels = numNodes - 1 + + nodes = (exports.createTestNode suite, _ for i in [0...numNodes]) + rels = (exports.createTestRelationship suite for i in [0...numRels]) + + nodeProps = $(nodes).pluck 'properties' + relProps = $(rels).pluck 'properties' + + params = {} + for props, i in nodeProps + params["nodeProps#{i}"] = props + for props, i in relProps + params["relProps#{i}"] = props + + query = '' + for node, i in nodes + query += "CREATE (node#{i}:#{exports.TEST_LABEL} {nodeProps#{i}}) \n" + for rel, i in rels + relStr = "-[rel#{i}:#{exports.TEST_REL_TYPE} {relProps#{i}}]->" + query += "CREATE (node#{i}) #{relStr} (node#{i + 1}) \n" + query += 'RETURN ' + query += ("ID(node#{i})" for node, i in nodes).join ', ' + if rels.length + query += ', ' + query += ("ID(rel#{i})" for rel, i in rels).join ', ' + + # NOTE: Using the old Cypher endpoint here. We don't want to rely on this + # driver's Cypher implementation, nor re-implement the (more complex) new + # endpoint here. This does, however, rely on this driver's HTTP support in + # general, but not on its ability to parse nodes and relationships. + # http://neo4j.com/docs/stable/rest-api-cypher.html#rest-api-use-parameters + {data} = exports.DB.http + method: 'POST' + path: '/db/data/cypher' + body: {query, params} + , _ + + [row] = data + + for node, i in nodes + node._id = row[i] + + for rel, i in rels + rel._id = row[numNodes + i] + rel._fromId = nodes[i]._id + rel._toId = nodes[i + 1]._id + + results = [] + + for node, i in nodes + results.push node + results.push rels[i] if rels[i] + + results + +# +# Executes a Cypher query to delete the test graph created by `createTestGraph` +# for the given test suite (pass the suite's Node `module`). +# +exports.deleteTestGraph = (suite, _) -> + exports.DB.http + method: 'POST' + path: '/db/data/cypher' + body: + query: """ + MATCH (node:#{exports.TEST_LABEL} {suite: {suite}}) + OPTIONAL MATCH (node) \ + -[rel:#{exports.TEST_REL_TYPE} {suite: {suite}}]-> () + DELETE node, rel + """ + params: exports.createTestProperties suite + , _ diff --git a/test-new/http._coffee b/test-new/http._coffee new file mode 100644 index 0000000..c6c1bda --- /dev/null +++ b/test-new/http._coffee @@ -0,0 +1,346 @@ +# +# Tests for the GraphDatabase `http` method, e.g. the ability to make custom, +# arbitrary HTTP requests, and have responses parsed for nodes, rels, & errors. +# + +{expect} = require 'chai' +fixtures = require './fixtures' +fs = require 'fs' +helpers = require './util/helpers' +http = require 'http' +neo4j = require '../' +Request = require 'request' + + +## CONSTANTS + +FAKE_JSON_PATH = "#{__dirname}/fixtures/fake.json" +FAKE_JSON = require FAKE_JSON_PATH + + +## SHARED STATE + +{DB} = fixtures + +[TEST_NODE_A, TEST_NODE_B, TEST_REL] = [] + + +## HELPERS + +# +# Asserts that the given object is a proper HTTP client request, +# set to the given method and path, and optionally including the given headers. +# +expectRequest = (req, method, path, headers={}) -> + expect(req).to.be.an.instanceOf Request.Request + expect(req.method).to.equal method + expect(req.path).to.equal path + + # Special-case for headers since they're stored differently: + for name, val of headers + expect(req.getHeader name).to.equal val + +# +# Asserts that the given object is a proper HTTP client response, +# with the given status code, and by default, a JSON Content-Type. +# +expectResponse = (resp, statusCode, json=true) -> + expect(resp).to.be.an.instanceOf http.IncomingMessage + expect(resp.statusCode).to.equal statusCode + expect(resp.headers).to.be.an 'object' + + if json + expect(resp.headers['content-type']).to.match /^application\/json\b/ + +# +# Asserts that the given object is the root Neo4j object. +# +expectNeo4jRoot = (body) -> + expect(body).to.be.an 'object' + expect(body).to.have.keys 'data', 'management' + +# +# Streams the given Request response, and calls either the error callback +# (for failing tests) or the success callback (with the parsed JSON body). +# Also asserts that the response has the given status code. +# +streamRequestResponse = (req, statusCode, cbErr, cbBody) -> + body = null + + req.on 'error', cbErr + req.on 'close', -> cbErr new Error 'Response closed!' + + req.on 'response', (resp) -> + expectResponse resp, statusCode + + req.on 'data', (str) -> + body ?= '' + body += str + + req.on 'end', -> + try + body = JSON.parse body + catch err + return cbErr err + + cbBody body + + +## TESTS + +describe 'GraphDatabase::http', -> + + it 'should support simple GET requests by default', (_) -> + body = DB.http '/', _ + expectNeo4jRoot body + + it 'should support complex requests with options', (_) -> + body = DB.http + method: 'GET' + path: '/' + headers: + 'X-Foo': 'bar' + , _ + + expectNeo4jRoot body + + it 'should throw errors for 4xx responses by default', (done) -> + DB.http + method: 'POST' + path: '/' + , (err, body) -> + expect(err).to.exist() + expect(body).to.not.exist() + + helpers.expectRawError err, 'ClientError', + '405 Method Not Allowed response for POST /' + + done() + + it 'should properly parse Neo4j exceptions', (done) -> + DB.http + method: 'GET' + path: '/db/data/node/-1' + , (err, body) -> + expect(err).to.exist() + expect(body).to.not.exist() + + # Neo4j 2.2 returns a proper new-style error object for this case, + # but previous versions return an old-style error. + try + helpers.expectError err, + 'ClientError', 'Statement', 'EntityNotFound', + 'Cannot find node with id [-1] in database.' + catch assertionErr + # Check for the older case, but if this fails, + # throw the original assertion error, not this one. + try + helpers.expectOldError err, 404, 'NodeNotFoundException', + 'org.neo4j.server.rest.web.NodeNotFoundException', + 'Cannot find node with id [-1] in database.' + catch doubleErr + throw assertionErr + + done() + + it 'should support returning raw responses', (_) -> + resp = DB.http + method: 'GET' + path: '/' + raw: true + , _ + + expectResponse resp, 200 + expectNeo4jRoot resp.body + + it 'should not throw 4xx errors for raw responses', (_) -> + resp = DB.http + method: 'POST' + path: '/' + raw: true + , _ + + expectResponse resp, 405, false # Method Not Allowed, no JSON body + + it 'should throw native errors always', (done) -> + db = new neo4j.GraphDatabase 'http://idontexist.foobarbaz.nodeneo4j' + db.http + path: '/' + raw: true + , (err, resp) -> + expect(err).to.exist() + expect(resp).to.not.exist() + + # NOTE: *Not* using our `expectError` helpers here, because we + # explicitly don't wrap native (non-Neo4j) errors. + expect(err).to.be.an.instanceOf Error + expect(err.name).to.equal 'Error' + expect(err.code).to.equal 'ENOTFOUND' + expect(err.syscall).to.equal 'getaddrinfo' + expect(err.message).to.contain "#{err.syscall} #{err.code}" + # NOTE: Node 0.12 adds the hostname to the message. + expect(err.stack).to.contain '\n' + expect(err.stack.split('\n')[0]).to.equal \ + "#{err.name}: #{err.message}" + + done() + + it 'should support streaming responses', (done) -> + opts = + method: 'GET' + path: '/db/data/node/-1' + + req = DB.http opts + + expectRequest req, opts.method, opts.path + + streamRequestResponse req, 404, done, (body) -> + # Simplified error parsing; just verifying stream: + expect(body).to.be.an 'object' + expect(body.exception).to.equal 'NodeNotFoundException' + expect(body.message).to.equal ' + Cannot find node with id [-1] in database.' + # Neo4j 2.2 changed `stacktrace` to `stackTrace`: + expect(body.stackTrace or body.stacktrace).to.be.an 'array' + + done() + + it 'should support streaming responses, even if not requests', (done) -> + opts = + method: 'POST' + path: '/db/data/node' + body: FAKE_JSON + + req = DB.http opts + + # TODO: Should we also assert that the request has automatically added + # Content-Type and Content-Length headers? Not technically required? + expectRequest req, opts.method, opts.path + + streamRequestResponse req, 400, done, (body) -> + # Simplified error parsing; just verifying stream: + expect(body).to.be.an 'object' + expect(body.exception).to.equal 'PropertyValueException' + expect(body.message).to.equal 'Could not set property "object", + unsupported type: {foo={bar=baz}}' + # Neo4j 2.2 changed `stacktrace` to `stackTrace`: + expect(body.stackTrace or body.stacktrace).to.be.an 'array' + + done() + + it 'should support streaming both requests and responses', (done) -> + opts = + method: 'POST' + path: '/db/data/node' + headers: + 'Content-Type': 'application/json' + # NOTE: It seems that Neo4j needs an explicit Content-Length, + # at least for requests to this `POST /db/data/node` endpoint. + 'Content-Length': (fs.statSync FAKE_JSON_PATH).size + # Ideally, we would instead send this header for streaming: + # http://nodejs.org/api/http.html#http_request_write_chunk_encoding_callback + # 'Transfer-Encoding': 'chunked' + + req = DB.http opts + + expectRequest req, opts.method, opts.path, opts.headers + + # Now stream some fake JSON to the request: + fs.createReadStream(FAKE_JSON_PATH).pipe req + + streamRequestResponse req, 400, done, (body) -> + # Simplified error parsing; just verifying stream: + expect(body).to.be.an 'object' + expect(body.exception).to.equal 'PropertyValueException' + expect(body.message).to.equal 'Could not set property "object", + unsupported type: {foo={bar=baz}}' + # Neo4j 2.2 changed `stacktrace` to `stackTrace`: + expect(body.stackTrace or body.stacktrace).to.be.an 'array' + + done() + + + ## Object parsing: + + it '(create test graph)', (_) -> + [TEST_NODE_A, TEST_REL, TEST_NODE_B] = + fixtures.createTestGraph module, 2, _ + + it 'should parse nodes by default', (_) -> + node = DB.http + method: 'GET' + path: "/db/data/node/#{TEST_NODE_A._id}" + , _ + + expect(node).to.be.an.instanceOf neo4j.Node + expect(node).to.eql TEST_NODE_A + + it 'should parse relationships by default', (_) -> + rel = DB.http + method: 'GET' + path: "/db/data/relationship/#{TEST_REL._id}" + , _ + + expect(rel).to.be.an.instanceOf neo4j.Relationship + expect(rel).to.eql TEST_REL + + it 'should parse nested nodes & relationships by default', (_) -> + {data} = DB.http + method: 'POST' + path: '/db/data/cypher' + body: + query: ''' + START a = node({idA}) + MATCH (a) -[r]-> (b) + RETURN a, r, b + ''' + params: + idA: TEST_NODE_A._id + , _ + + [row] = data + [nodeA, rel, nodeB] = row + + expect(nodeA).to.be.an.instanceOf neo4j.Node + expect(nodeA).to.eql TEST_NODE_A + expect(nodeB).to.be.an.instanceOf neo4j.Node + expect(nodeB).to.eql TEST_NODE_B + expect(rel).to.be.an.instanceOf neo4j.Relationship + expect(rel).to.eql TEST_REL + + it 'should not parse nodes for raw responses', (_) -> + {body} = DB.http + method: 'GET' + path: "/db/data/node/#{TEST_NODE_A._id}" + raw: true + , _ + + expect(body).to.not.be.an.instanceOf neo4j.Node + + # NOTE: Neo4j <2.1.5 didn't return `metadata`, so can't rely on it: + if fixtures.DB_VERSION_STR >= '2.1.5' + expect(body.metadata).to.be.an 'object' + expect(body.metadata.id).to.equal TEST_NODE_A._id + expect(body.metadata.labels).to.eql TEST_NODE_A.labels + + expect(body.data).to.eql TEST_NODE_A.properties + + it 'should not parse relationships for raw responses', (_) -> + {body} = DB.http + method: 'GET' + path: "/db/data/relationship/#{TEST_REL._id}" + raw: true + , _ + + expect(body).to.not.be.an.instanceOf neo4j.Relationship + + # NOTE: Neo4j <2.1.5 didn't return `metadata`, so can't rely on it: + if fixtures.DB_VERSION_STR >= '2.1.5' + expect(body.metadata).to.be.an 'object' + expect(body.metadata.id).to.equal TEST_REL._id + + expect(body.type).to.equal TEST_REL.type + expect(body.data).to.eql TEST_REL.properties + + it '(delete test graph)', (_) -> + fixtures.deleteTestGraph module, _ diff --git a/test-new/indexes._coffee b/test-new/indexes._coffee new file mode 100644 index 0000000..c28dc07 --- /dev/null +++ b/test-new/indexes._coffee @@ -0,0 +1,287 @@ +# +# Tests for indexing, e.g. creating, retrieivng, and dropping indexes. +# In the process, also tests that indexing actually has an effect. +# + +{expect} = require 'chai' +fixtures = require './fixtures' +helpers = require './util/helpers' +neo4j = require '../' + + +## SHARED STATE + +{DB, TEST_LABEL} = fixtures + +[TEST_NODE_A, TEST_REL, TEST_NODE_B] = [] + +# Important: we generate a random property to guarantee no index exists yet. +TEST_PROP = "index_#{helpers.getRandomStr()}" +TEST_VALUE = 'test' + +# These are the indexes that exist before this test suite runs... +ORIG_INDEXES_ALL = null # ...Across all labels +ORIG_INDEXES_LABEL = null # ...On our TEST_LABEL + +# And this is the index we create: +TEST_INDEX = null + +# Cypher query + params to test our index: +TEST_CYPHER = + query: """ + MATCH (n:#{TEST_LABEL}) + USING INDEX n:#{TEST_LABEL}(#{TEST_PROP}) + WHERE n.#{TEST_PROP} = {value} + RETURN n + ORDER BY ID(n) + """ + params: + value: TEST_VALUE + + +## HELPERS + +expectIndex = (index, label, property) -> + expect(index).to.be.an.instanceOf neo4j.Index + expect(index.label).to.equal label if label + expect(index.property).to.equal property if property + +expectIndexes = (indexes, label) -> + expect(indexes).to.be.an 'array' + for index in indexes + expectIndex index, label + + +## TESTS + +describe 'Indexes', -> + + # IMPORTANT: Mocha requires all test steps to be at the same nesting level + # for them to all execute in order. Hence "setup" and "teardown" wrappers. + + describe '(setup)', -> + + it '(create test nodes)', (_) -> + [TEST_NODE_A, TEST_REL, TEST_NODE_B] = + fixtures.createTestGraph module, 2, _ + + it '(set test properties)', (_) -> + DB.cypher + query: """ + START n = node({ids}) + SET n.#{TEST_PROP} = {value} + """ + params: + ids: [TEST_NODE_A._id, TEST_NODE_B._id] + value: TEST_VALUE + , _ + + # Update our local instances too: + TEST_NODE_A.properties[TEST_PROP] = TEST_VALUE + TEST_NODE_B.properties[TEST_PROP] = TEST_VALUE + + # HACK: Only needed because a test that uses it below isn't Streamline. + it '(query db version)', (_) -> + fixtures.queryDbVersion _ + + + describe '(before index created)', -> + + it 'should support listing all indexes', (_) -> + indexes = DB.getIndexes _ + expectIndexes indexes + + ORIG_INDEXES_ALL = indexes + + it 'should support listing indexes for a particular label', (_) -> + indexes = DB.getIndexes TEST_LABEL, _ + expectIndexes indexes, TEST_LABEL + + ORIG_INDEXES_LABEL = indexes + + it 'should support querying for specific index', (_) -> + exists = DB.hasIndex + label: TEST_LABEL + property: TEST_PROP + , _ + + expect(exists).to.equal false + + it '(verify index doesn’t exist yet)', (done) -> + DB.cypher TEST_CYPHER, (err, results) -> + # NOTE: Our test Cypher uses `USING INDEX` to test index usage, + # but Neo4j 2.3+ no longer errors by default on invalid hints: + # http://neo4j.com/docs/stable/configuration-settings.html#config_dbms.cypher.hints.error + # So we explicitly guard against that, and just continue then. + # TODO: Would be nice to test the index another way... + try + expect(err).to.exist() + expect(results).to.not.exist() + catch assertionErr + # HACK: Because this test isn't a Streamline function, + # relying on `fixtures.queryDbVersion` having been called + # already, as part of the suite's setup. + if fixtures.DB_VERSION_NUM >= 2.3 + return done() + else + throw assertionErr + + # NOTE: At some point, Neo4j regressed and improperly returned + # DatabaseError for this case, rather than ClientError. + # https://github.com/neo4j/neo4j/issues/5902 + expMessage = """ + No such index found. + Label: `#{TEST_LABEL}` + Property name: `#{TEST_PROP}` + """ + try + helpers.expectError err, + 'ClientError', 'Schema', 'NoSuchIndex', expMessage + catch assertionErr + # Check for the regression case, but if this fails, + # throw the original assertion error, not this one. + # + # HACK: The newlines in the error message are atypical, + # and too hard to work with (since our friendly error code + # indents multi-line messages and stack traces), + # so we hard-code simplified assertions here. + # + try + expect(err).to.be.an.instanceOf neo4j.DatabaseError + expect(err.neo4j?.code).to.equal \ + 'Neo.DatabaseError.Statement.ExecutionFailure' + expect(err.neo4j?.message).to.equal expMessage + catch doubleErr + throw assertionErr + + done() + + it 'should support creating index', (_) -> + index = DB.createIndex + label: TEST_LABEL + property: TEST_PROP + , _ + + expectIndex index, TEST_LABEL, TEST_PROP + + TEST_INDEX = index + + + describe '(after index created)', -> + + it '(verify by re-listing all indexes)', (_) -> + indexes = DB.getIndexes _ + expect(indexes).to.have.length ORIG_INDEXES_ALL.length + 1 + expect(indexes).to.contain TEST_INDEX + + it '(verify by re-listing indexes for test label)', (_) -> + indexes = DB.getIndexes TEST_LABEL, _ + expect(indexes).to.have.length ORIG_INDEXES_LABEL.length + 1 + expect(indexes).to.contain TEST_INDEX + + it '(verify by re-querying specific test index)', (_) -> + exists = DB.hasIndex + label: TEST_LABEL + property: TEST_PROP + , _ + + expect(exists).to.equal true + + # TODO: This sometimes fails because the index hasn't come online yet, + # but Neo4j's REST API doesn't return index online/offline status. + # We may need to change this to retry/poll for some time. + it.skip '(verify with test query)', (_) -> + results = DB.cypher TEST_CYPHER, _ + + expect(results).to.eql [ + n: TEST_NODE_A + , + n: TEST_NODE_B + ] + + it 'should be idempotent on repeat creates of index', (_) -> + index = DB.createIndex + label: TEST_LABEL + property: TEST_PROP + , _ + + expect(index).to.not.exist() + + it 'should support dropping index', (_) -> + dropped = DB.dropIndex + label: TEST_LABEL + property: TEST_PROP + , _ + + expect(dropped).to.equal true + + + describe '(after index dropped)', -> + + it '(verify by re-listing all indexes)', (_) -> + indexes = DB.getIndexes _ + expect(indexes).to.eql ORIG_INDEXES_ALL + + it '(verify by re-listing indexes for test label)', (_) -> + indexes = DB.getIndexes TEST_LABEL, _ + expect(indexes).to.eql ORIG_INDEXES_LABEL + + it '(verify by re-querying specific test index)', (_) -> + exists = DB.hasIndex + label: TEST_LABEL + property: TEST_PROP + , _ + + expect(exists).to.equal false + + it 'should be idempotent on repeat drops of constraint', (_) -> + dropped = DB.dropIndex + label: TEST_LABEL + property: TEST_PROP + , _ + + expect(dropped).to.equal false + + + describe '(misc)', -> + + fail = -> throw new Error 'Callback should not have been called' + + it 'should require both label and property to query specific index', -> + for fn in [ + -> DB.hasIndex null, fail + -> DB.hasIndex '', fail + -> DB.hasIndex {}, fail + -> DB.hasIndex TEST_LABEL, fail + -> DB.hasIndex {label: TEST_LABEL}, fail + -> DB.hasIndex {property: TEST_PROP}, fail + ] + expect(fn).to.throw TypeError, /label and property required/i + + it 'should require both label and property to create index', -> + for fn in [ + -> DB.createIndex null, fail + -> DB.createIndex '', fail + -> DB.createIndex {}, fail + -> DB.createIndex TEST_LABEL, fail + -> DB.createIndex {label: TEST_LABEL}, fail + -> DB.createIndex {property: TEST_PROP}, fail + ] + expect(fn).to.throw TypeError, /label and property required/i + + it 'should require both label and property to drop index', -> + for fn in [ + -> DB.dropIndex null, fail + -> DB.dropIndex '', fail + -> DB.dropIndex {}, fail + -> DB.dropIndex TEST_LABEL, fail + -> DB.dropIndex {label: TEST_LABEL}, fail + -> DB.dropIndex {property: TEST_PROP}, fail + ] + expect(fn).to.throw TypeError, /label and property required/i + + + describe '(teardown)', -> + + it '(delete test nodes)', (_) -> + fixtures.deleteTestGraph module, _ diff --git a/test-new/schema._coffee b/test-new/schema._coffee new file mode 100644 index 0000000..04a311f --- /dev/null +++ b/test-new/schema._coffee @@ -0,0 +1,50 @@ +# +# Tests for basic schema management, e.g. retrieving labels, property keys, and +# relationship types. This does *not* cover indexes and constraints. +# + +{expect} = require 'chai' +fixtures = require './fixtures' +neo4j = require '../' + + +## SHARED STATE + +{DB, TEST_LABEL, TEST_REL_TYPE} = fixtures + +[TEST_NODE_A, TEST_NODE_B, TEST_REL] = [] + + +## TESTS + +describe 'Schema', -> + + it '(create test graph)', (_) -> + [TEST_NODE_A, TEST_REL, TEST_NODE_B] = + fixtures.createTestGraph module, 2, _ + + it 'should support listing all labels', (_) -> + labels = DB.getLabels _ + + expect(labels).to.be.an 'array' + expect(labels).to.not.be.empty() + expect(labels).to.contain TEST_LABEL + + it 'should support listing all property keys', (_) -> + keys = DB.getPropertyKeys _ + + expect(keys).to.be.an 'array' + expect(keys).to.not.be.empty() + + for key of TEST_NODE_A.properties + expect(keys).to.contain key + + it 'should support listing all relationship types', (_) -> + types = DB.getRelationshipTypes _ + + expect(types).to.be.an 'array' + expect(types).to.not.be.empty() + expect(types).to.contain TEST_REL_TYPE + + it '(delete test graph)', (_) -> + fixtures.deleteTestGraph module, _ diff --git a/test-new/transactions._coffee b/test-new/transactions._coffee new file mode 100644 index 0000000..3e6e203 --- /dev/null +++ b/test-new/transactions._coffee @@ -0,0 +1,717 @@ +# +# Tests for Transaction support, e.g. the ability to make multiple queries, +# across network requests, in a single transaction; commit; rollback; etc. +# + +{expect} = require 'chai' +fixtures = require './fixtures' +flows = require 'streamline/lib/util/flows' +helpers = require './util/helpers' +neo4j = require '../' + + +## SHARED STATE + +{DB, TEST_LABEL} = fixtures + +[TEST_NODE_A, TEST_NODE_B, TEST_REL] = [] + + +## HELPERS + +# Calls the given asynchronous function with a placeholder callback, and +# immediately returns a "future" that can be called with a real callback. +# TODO: Achieve this with Streamline futures once we upgrade to 1.0. +# https://github.com/Sage/streamlinejs/issues/181#issuecomment-148926447 +# Need this manual implementation for now, since `db.cypher` isn't Streamline. +defer = (fn) -> + # Modeled off of Streamline's own futures implementation: + # https://bjouhier.wordpress.com/2011/04/04/currying-the-callback-or-the-essence-of-futures/ + results = null + + cb = (_results...) -> + results = _results + + fn (_results...) -> + cb _results... + + return (_cb) -> + if results + _cb results... + else + cb = _cb + +# Neo4j <2.2.6 used to keep transactions open on client and transient errors -- +# even though transactions would always fail to commit later in those cases. +# Neo4j 2.2.6 fixed this, so transactions automatically roll back then. +# This helper accounts for this change by deriving the expected state after +# client and transient errors, based on the current Neo4j version. +# It also manually rolls the transaction back then, if Neo4j <2.2.6. +expectTxErrorRolledBack = (tx, _) -> + fixtures.queryDbVersion _ + + if fixtures.DB_VERSION_STR < '2.2.6' + expect(tx.state).to.equal tx.STATE_OPEN + tx.rollback _ + + expect(tx.state).to.equal tx.STATE_ROLLED_BACK + + +## TESTS + +describe 'Transactions', -> + + it 'should support simple queries', (_) -> + tx = DB.beginTransaction() + + [{foo}] = tx.cypher 'RETURN "bar" AS foo', _ + + expect(foo).to.equal 'bar' + + it 'should convey pending state, and reject concurrent requests', (done) -> + tx = DB.beginTransaction() + expect(tx.state).to.equal tx.STATE_OPEN + + fn = -> + tx.cypher 'RETURN "bar" AS foo', cb + expect(tx.state).to.equal tx.STATE_PENDING + + cb = (err, results) -> + expect(err).to.not.exist() + expect(tx.state).to.equal tx.STATE_OPEN + done() + + fn() + expect(fn).to.throw neo4j.ClientError, /concurrent requests/i + + it '(create test graph)', (_) -> + [TEST_NODE_A, TEST_REL, TEST_NODE_B] = + fixtures.createTestGraph module, 2, _ + + it 'should isolate effects', (_) -> + tx = DB.beginTransaction() + + # NOTE: It's important for us to create something new here, rather than + # modify something existing. Otherwise, since we don't explicitly + # rollback our open transaction at the end of this test, Neo4j sits and + # waits for it to expire before returning other queries that touch the + # existing graph -- including our last "delete test graph" step. + # To that end, we test creating a new node here. + + {labels, properties} = fixtures.createTestNode module, _ + + [{node}] = tx.cypher + query: """ + CREATE (node:#{TEST_LABEL} {properties}) + RETURN node + """ + params: {properties} + , _ + + expect(node).to.be.an.instanceOf neo4j.Node + expect(node.properties).to.eql properties + expect(node.labels).to.eql labels + expect(node._id).to.be.a 'number' + + # Outside the transaction, we shouldn't see this newly created node: + results = DB.cypher + query: """ + MATCH (node:#{TEST_LABEL}) + WHERE #{( + # NOTE: Cypher doesn’t support directly comparing nodes and + # property bags, so we have to compare each property. + # HACK: CoffeeLint thinks the below is bad indentation. + # https://github.com/clutchski/coffeelint/issues/456 + # coffeelint: disable=indentation + for prop of properties + "node.#{prop} = {properties}.#{prop}" + # coffeelint: enable=indentation + # HACK: CoffeeLint also thinks the below is double quotes! + # https://github.com/clutchski/coffeelint/issues/368 + # coffeelint: disable=no_unnecessary_double_quotes + ).join ' AND '} + RETURN node + """ + # coffeelint: enable=no_unnecessary_double_quotes + params: {properties} + , _ + + expect(results).to.be.empty() + + it 'should support committing, and reject subsequent requests', (_) -> + tx = DB.beginTransaction() + + [{nodeA}] = tx.cypher + query: ''' + START nodeA = node({idA}) + SET nodeA.test = 'committing' + RETURN nodeA + ''' + params: + idA: TEST_NODE_A._id + , _ + + expect(nodeA.properties.test).to.equal 'committing' + + expect(tx.state).to.equal tx.STATE_OPEN + tx.commit _ + expect(tx.state).to.equal tx.STATE_COMMITTED + + expect(-> tx.cypher 'RETURN "bar" AS foo') + .to.throw neo4j.ClientError, /been committed/i + + # Outside of the transaction, we should see this change now: + [{nodeA}] = DB.cypher + query: ''' + START nodeA = node({idA}) + RETURN nodeA + ''' + params: + idA: TEST_NODE_A._id + , _ + + expect(nodeA.properties.test).to.equal 'committing' + + it 'should fail when committing without a callback', (_) -> + tx = DB.beginTransaction() + expect(tx.commit).to.throw Error + + it 'should support committing before any queries', (_) -> + tx = DB.beginTransaction() + expect(tx.state).to.equal tx.STATE_OPEN + + tx.commit _ + expect(tx.state).to.equal tx.STATE_COMMITTED + + it 'should support auto-committing', (_) -> + tx = DB.beginTransaction() + + # Rather than test auto-committing on the first query, which doesn't + # actually create a new transaction, auto-commit on the second. + + [{nodeA}] = tx.cypher + query: ''' + START nodeA = node({idA}) + SET nodeA.test = 'auto-committing' + SET nodeA.i = 1 + RETURN nodeA + ''' + params: + idA: TEST_NODE_A._id + , _ + + expect(nodeA.properties.test).to.equal 'auto-committing' + expect(nodeA.properties.i).to.equal 1 + + expect(tx.state).to.equal tx.STATE_OPEN + + [{nodeA}] = tx.cypher + query: ''' + START nodeA = node({idA}) + SET nodeA.i = 2 + RETURN nodeA + ''' + params: + idA: TEST_NODE_A._id + commit: true + , _ + + expect(nodeA.properties.test).to.equal 'auto-committing' + expect(nodeA.properties.i).to.equal 2 + + expect(tx.state).to.equal tx.STATE_COMMITTED + + expect(-> tx.cypher 'RETURN "bar" AS foo') + .to.throw neo4j.ClientError, /been committed/i + + # Outside of the transaction, we should see this change now: + [{nodeA}] = DB.cypher + query: ''' + START nodeA = node({idA}) + RETURN nodeA + ''' + params: + idA: TEST_NODE_A._id + , _ + + expect(nodeA.properties.test).to.equal 'auto-committing' + expect(nodeA.properties.i).to.equal 2 + + it 'should support rolling back, and reject subsequent requests', (_) -> + tx = DB.beginTransaction() + + [{nodeA}] = tx.cypher + query: ''' + START a = node({idA}) + SET a.test = 'rolling back' + RETURN a AS nodeA + ''' + params: + idA: TEST_NODE_A._id + , _ + + expect(nodeA.properties.test).to.equal 'rolling back' + + expect(tx.state).to.equal tx.STATE_OPEN + tx.rollback _ + expect(tx.state).to.equal tx.STATE_ROLLED_BACK + + expect(-> tx.cypher 'RETURN "bar" AS foo') + .to.throw neo4j.ClientError, /been rolled back/i + + # Back outside this transaction now, the change should *not* be visible: + [{nodeA}] = DB.cypher + query: ''' + START a = node({idA}) + RETURN a AS nodeA + ''' + params: + idA: TEST_NODE_A._id + , _ + + expect(nodeA.properties.test).to.not.equal 'rolling back' + + it 'should support rolling back before any queries', (_) -> + tx = DB.beginTransaction() + expect(tx.state).to.equal tx.STATE_OPEN + + tx.rollback _ + expect(tx.state).to.equal tx.STATE_ROLLED_BACK + + # NOTE: Skipping this test by default, because it's slow (we have to pause + # one second; see note within) and not really a mission-critical feature. + it.skip 'should support renewing (slow)', (_) -> + tx = DB.beginTransaction() + + [{nodeA}] = tx.cypher + query: ''' + START a = node({idA}) + SET a.test = 'renewing' + RETURN a AS nodeA + ''' + params: + idA: TEST_NODE_A._id + , _ + + expect(nodeA.properties.test).to.equal 'renewing' + + expect(tx.expiresAt).to.be.an.instanceOf Date + expect(tx.expiresAt).to.be.greaterThan new Date + expect(tx.expiresIn).to.be.a 'number' + expect(tx.expiresIn).to.be.greaterThan 0 + expect(tx.expiresIn).to.equal tx.expiresAt - new Date + + # NOTE: We can't easily test transactions actually expiring (that would + # take too long, and there's no way for the client to shorten the time), + # so we can't test that renewing actually *works* / has an effect. + # We can only test that it *appears* to work / have an effect. + # + # NOTE: Neo4j's expiry appears to have a granularity of one second, + # so to be robust (local requests are frequently faster than that), + # we pause a second first. + + oldExpiresAt = tx.expiresAt + setTimeout _, 1000 # TODO: Provide visual feedback? + + expect(tx.state).to.equal tx.STATE_OPEN + tx.renew _ + expect(tx.state).to.equal tx.STATE_OPEN + + expect(tx.expiresAt).to.be.an.instanceOf Date + expect(tx.expiresAt).to.be.greaterThan new Date + expect(tx.expiresAt).to.be.greaterThan oldExpiresAt + expect(tx.expiresIn).to.be.a 'number' + expect(tx.expiresIn).to.be.greaterThan 0 + expect(tx.expiresIn).to.equal tx.expiresAt - new Date + + # To prevent Neo4j from hanging at the end waiting for this transaction + # to commit or expire (since it touches the existing graph, and our last + # step is to delete the existing graph), roll this transaction back. + tx.rollback _ + expect(tx.state).to.equal tx.STATE_ROLLED_BACK + + # We also ensure that renewing didn't cause the transaction to commit. + [{nodeA}] = DB.cypher + query: ''' + START a = node({idA}) + RETURN a AS nodeA + ''' + params: + idA: TEST_NODE_A._id + , _ + + expect(nodeA.properties.test).to.not.equal 'renewing' + + it 'should properly handle (fatal) client errors', (_) -> + tx = DB.beginTransaction() + + [{nodeA}] = tx.cypher + query: ''' + START nodeA = node({idA}) + SET nodeA.test = 'client errors' + SET nodeA.i = 1 + RETURN nodeA + ''' + params: + idA: TEST_NODE_A._id + , _ + + expect(nodeA.properties.test).to.equal 'client errors' + expect(nodeA.properties.i).to.equal 1 + + # Now trigger a client error by omitting a referenced parameter. + # For precision, implementing this step without Streamline. + do (cont=_) => + tx.cypher + query: ''' + START nodeA = node({idA}) + SET nodeA.i = 2 + RETURN {foo} + ''' + params: + idA: TEST_NODE_A._id + , (err, results) => + expect(err).to.exist() + helpers.expectError err, 'ClientError', 'Statement', + 'ParameterMissing', 'Expected a parameter named foo' + cont() + + # All transaction errors, including client ones, are fatal, so the + # transaction should be rolled back -- except in Neo4j <2.2.6. + # See the documentation of `expectTxErrorRolledBack` for details: + expectTxErrorRolledBack tx, _ + + expect(-> tx.cypher 'RETURN "bar" AS foo') + .to.throw neo4j.ClientError, /been rolled back/i + + # Back outside this transaction now, the change should *not* be visible: + [{nodeA}] = DB.cypher + query: ''' + START a = node({idA}) + RETURN a AS nodeA + ''' + params: + idA: TEST_NODE_A._id + , _ + + expect(nodeA.properties.test).to.not.equal 'client errors' + + # NOTE: Skipping this test by default, because it's slow (we have to pause + # one second; see note within) and not crucial, unique test coverage. + it.skip 'should properly handle (fatal) transient errors (slow)', (_) -> + # The main transient error we can trigger is a DeadlockDetected error. + # We can do this by having two separate transactions take locks on the + # same two nodes, across two queries, but in opposite order. + # (Taking a lock on a node just means writing to the node.) + tx1 = DB.beginTransaction() + tx2 = DB.beginTransaction() + + [[{nodeA}], [{nodeB}]] = flows.collect _, [ + defer tx1.cypher.bind tx1, + query: ''' + START nodeA = node({idA}) + SET nodeA.test = 'transient errors' + SET nodeA.tx = 1 + RETURN nodeA + ''' + params: + idA: TEST_NODE_A._id + + defer tx2.cypher.bind tx2, + query: ''' + START nodeB = node({idB}) + SET nodeB.test = 'transient errors' + SET nodeB.tx = 2 + RETURN nodeB + ''' + params: + idB: TEST_NODE_B._id + ] + + expect(nodeA.properties.test).to.equal 'transient errors' + expect(nodeA.properties.tx).to.equal 1 + expect(nodeB.properties.test).to.equal 'transient errors' + expect(nodeB.properties.tx).to.equal 2 + + # Now have each transaction attempt to lock the other's node. + # This should trigger a DeadlockDetected error in one transaction. + # It can also happen in the other, however, depending on timing. + # HACK: To simplify this test, we thus add a pause, to reduce the + # chance that both transactions will fail. This isn't bulletproof. + + # Kick off the first transaction's query asynchronously... + future1 = defer tx1.cypher.bind tx1, + query: ''' + START nodeB = node({idB}) + SET nodeB.tx = 1 + RETURN nodeB + ''' + params: + idB: TEST_NODE_B._id + + # Then pause for a bit... + setTimeout _, 1000 + + # Now make the second transaction's query (synchronously)... + # which should fail right right away with a transient error. + # For precision, implementing this step without Streamline. + do (cont=_) -> + tx2.cypher + query: ''' + START nodeA = node({idA}) + SET nodeA.tx = 2 + RETURN nodeA + ''' + params: + idA: TEST_NODE_A._id + , (err, results) -> + expect(err).to.exist() + # NOTE: Deadlock detected messages aren't predictable, + # so having the assertion for it simply check itself: + helpers.expectError err, 'TransientError', 'Transaction', + 'DeadlockDetected', err.neo4j?.message or '???' + cont() + + # All transaction errors, including transient ones, are fatal, so this + # second transaction should be rolled back -- except in Neo4j <2.2.6. + # See the documentation of `expectTxErrorRolledBack` for details: + expectTxErrorRolledBack tx2, _ + + # That should free up the first transaction to succeed, so wait for it: + [{nodeB}] = future1 _ + + # The second transaction's effects should *not* be visible within the + # first transaction; only the first transaction's effects should be: + expect(nodeB.properties.test).to.not.equal 'transient errors' + expect(nodeB.properties.tx).to.equal 1 + + # To prevent Neo4j from hanging at the end waiting for this transaction + # to commit or expire (since it touches the existing graph, and our last + # step is to delete the existing graph), roll this transaction back. + expect(tx1.state).to.equal tx1.STATE_OPEN + tx1.rollback _ + expect(tx1.state).to.equal tx1.STATE_ROLLED_BACK + + it 'should properly handle (fatal) database errors', (_) -> + tx = DB.beginTransaction() + + # Important: don't auto-commit in the first query, because that doesn't + # let us test that a transaction gets *returned* and *then* rolled back. + [{nodeA}] = tx.cypher + query: ''' + START nodeA = node({idA}) + SET nodeA.test = 'database errors' + SET nodeA.i = 1 + RETURN nodeA + ''' + params: + idA: TEST_NODE_A._id + , _ + + expect(nodeA.properties.test).to.equal 'database errors' + expect(nodeA.properties.i).to.equal 1 + + # HACK: Depending on a known bug to trigger a DatabaseError; + # that makes this test brittle, since the bug could get fixed! + # https://github.com/neo4j/neo4j/issues/3870#issuecomment-76650113 + # For precision, implementing this step without Streamline. + do (cont=_) => + tx.cypher + query: 'CREATE (n {props})' + params: + props: {foo: null} + , (err, results) => + expect(err).to.exist() + helpers.expectError err, + 'DatabaseError', 'Statement', 'ExecutionFailure', + 'scala.MatchError: (foo,null) (of class scala.Tuple2)' + cont() + + expect(tx.state).to.equal tx.STATE_ROLLED_BACK + + expect(-> tx.cypher 'RETURN "bar" AS foo') + .to.throw neo4j.ClientError, /been rolled back/i + + # The change should thus *not* be visible back outside the transaction: + [{nodeA}] = DB.cypher + query: ''' + START a = node({idA}) + RETURN a AS nodeA + ''' + params: + idA: TEST_NODE_A._id + , _ + + expect(nodeA.properties.test).to.not.equal 'database errors' + + it 'should properly handle (fatal) errors during commit', (_) -> + tx = DB.beginTransaction() + + # Important: don't auto-commit in the first query, because that doesn't + # let us test that a transaction gets *returned* and *then* rolled back. + [{nodeA}] = tx.cypher + query: ''' + START nodeA = node({idA}) + SET nodeA.test = 'errors during commit' + SET nodeA.i = 1 + RETURN nodeA + ''' + params: + idA: TEST_NODE_A._id + , _ + + expect(nodeA.properties.test).to.equal 'errors during commit' + expect(nodeA.properties.i).to.equal 1 + + # Now trigger a client error by omitting a referenced parameter. + # For precision, implementing this step without Streamline. + do (cont=_) => + tx.cypher + query: ''' + START nodeA = node({idA}) + SET nodeA.i = 2 + RETURN {foo} + ''' + params: + idA: TEST_NODE_A._id + commit: true + , (err, results) => + expect(err).to.exist() + helpers.expectError err, 'ClientError', 'Statement', + 'ParameterMissing', 'Expected a parameter named foo' + cont() + + # All transaction errors are fatal during commit, even in Neo4j <2.2.6: + expect(tx.state).to.equal tx.STATE_ROLLED_BACK + + it 'should properly handle (fatal) errors on the first query', (_) -> + tx = DB.beginTransaction() + expect(tx.state).to.equal tx.STATE_OPEN + + # For precision, implementing this step without Streamline. + do (cont=_) => + tx.cypher 'RETURN {foo}', (err, results) => + expect(err).to.exist() + helpers.expectError err, 'ClientError', 'Statement', + 'ParameterMissing', 'Expected a parameter named foo' + cont() + + # All transaction errors, including on the first query, are fatal, + # so the transaction should be rolled back -- except in Neo4j <2.2.6. + # See the documentation of `expectTxErrorRolledBack` for details: + expectTxErrorRolledBack tx, _ + + it 'should properly handle (fatal) errors + on an auto-commit first query', (_) -> + tx = DB.beginTransaction() + expect(tx.state).to.equal tx.STATE_OPEN + + # For precision, implementing this step without Streamline. + do (cont=_) => + tx.cypher + query: 'RETURN {foo}' + commit: true + , (err, results) => + expect(err).to.exist() + helpers.expectError err, 'ClientError', 'Statement', + 'ParameterMissing', 'Expected a parameter named foo' + cont() + + # All transaction errors are fatal during commit, even in Neo4j <2.2.6, + # and even on the first query: + expect(tx.state).to.equal tx.STATE_ROLLED_BACK + + it 'should properly handle (fatal) errors with batching', (_) -> + tx = DB.beginTransaction() + + results = tx.cypher [ + query: ''' + START nodeA = node({idA}) + SET nodeA.test = 'errors with batching' + ''' + params: + idA: TEST_NODE_A._id + , + query: ''' + START nodeA = node({idA}) + SET nodeA.i = 1 + RETURN nodeA + ''' + params: + idA: TEST_NODE_A._id + ], _ + + expect(results).to.be.an 'array' + expect(results).to.have.length 2 + + for result in results + expect(result).to.be.an 'array' + + expect(results[0]).to.be.empty() + expect(results[1]).to.have.length 1 + + [{nodeA}] = results[1] + + expect(nodeA.properties.test).to.equal 'errors with batching' + expect(nodeA.properties.i).to.equal 1 + + expect(tx.state).to.equal tx.STATE_OPEN + + # Now trigger a client error by omitting a referenced parameter. + # For precision, implementing this step without Streamline. + do (cont=_) => + tx.cypher + queries: [ + query: ''' + START nodeA = node({idA}) + SET nodeA.i = 2 + RETURN nodeA + ''' + params: + idA: TEST_NODE_A._id + lean: true + , + '(syntax error)' + , + query: ''' + START nodeA = node({idA}) + SET nodeA.i = 3 + RETURN nodeA + ''' + params: + idA: TEST_NODE_A._id + ] + , (err, results) => + expect(err).to.exist() + + # Simplified error checking, since the message is complex: + expect(err).to.be.an.instanceOf neo4j.ClientError + expect(err.neo4j).to.be.an 'object' + expect(err.neo4j.code).to.equal \ + 'Neo.ClientError.Statement.InvalidSyntax' + + expect(results).to.be.an 'array' + expect(results).to.have.length 1 + + [result] = results + + expect(result).to.be.an 'array' + expect(result).to.have.length 1 + + [{nodeA}] = result + + # We requested `lean: true`, so `nodeA` is just properties: + expect(nodeA.test).to.equal 'errors with batching' + expect(nodeA.i).to.equal 2 + + cont() + + # All transaction errors, including client ones in a batch, are fatal, + # so the transaction should be rolled back -- except in Neo4j <2.2.6. + # See the documentation of `expectTxErrorRolledBack` for details: + expectTxErrorRolledBack tx, _ + + it 'should support streaming (TODO)' + + it '(delete test graph)', (_) -> + fixtures.deleteTestGraph module, _ diff --git a/test-new/util/helpers._coffee b/test-new/util/helpers._coffee new file mode 100644 index 0000000..b4a9a38 --- /dev/null +++ b/test-new/util/helpers._coffee @@ -0,0 +1,141 @@ +# +# NOTE: This file is within a `util` subdirectory, rather than within the +# top-level `test` directory, in order to not have Mocha treat it like a test. +# + +{expect} = require 'chai' +http = require 'http' +neo4j = require '../../' + + +# +# Chai doesn't have a `beginsWith` assertion, so this approximates that: +# Asserts that the given string begins with the given prefix. +# +exports.expectPrefix = (str, prefix) -> + expect(str).to.be.a 'string' + expect(str.slice 0, prefix.length).to.equal prefix + + +# +# Helper used by all the below methods that covers all specific error cases. +# Asserts that the given error at least adheres to our base Error contract. +# +exports._expectBaseError = (err, classification) -> + expect(err).to.be.an.instanceOf neo4j[classification] # e.g. DatabaseError + expect(err.name).to.equal "neo4j.#{classification}" + + expect(err.message).to.be.a 'string' + expect(err.stack).to.be.a 'string' + expect(err.stack).to.contain '\n' + + exports.expectPrefix err.stack, "#{err.name}: #{err.message}" + + +# +# Asserts that the given object is an instance of the appropriate Neo4j Error +# subclass, representing the given *new-style* Neo4j v2 error info. +# +exports.expectError = (err, classification, category, title, message) -> + code = "Neo.#{classification}.#{category}.#{title}" + codePlusMessage = "[#{code}] #{message}" + + exports._expectBaseError err, classification + + expect(err.neo4j).to.be.an 'object' + expect(err.neo4j.code).to.equal code + + # Neo4j can return its own Java stack trace, which changes our logic. + # So check for the simpler case first, then short-circuit: + if not err.neo4j.stackTrace + expect(err.message).to.equal codePlusMessage + expect(err.neo4j.message).to.equal message + return + + # Otherwise, we construct our own stack trace from the Neo4j stack trace, + # by setting our message to the Neo4j stack trace. + # Neo4j stack traces can have messages with slightly more detail than the + # returned `message` property, so we test that via "contains". + expect(err.neo4j.stackTrace).to.be.a 'string' + expect(err.neo4j.message).to.be.a 'string' + expect(message).to.contain err.neo4j.message + + # Finally, we test that our returned message indeed includes the Neo4j stack + # trace, after the expected message part (which can be multi-line). + # We test just the first line of the stack trace for simplicity. + # (Subsequent lines can be different, e.g. "Caused by ..."). + # + # HACK: The one exception to this, it seems, is deadlock detected errors... + # Special-casing those for now; may be worth investigating later. + # + exports.expectPrefix err.message, codePlusMessage + [errMessageStackLine1, ...] = + (err.message.slice 0, codePlusMessage.length).split '\n' + [neo4jStackTraceLine1, ...] = err.neo4j.stackTrace.split '\n' + unless code is 'Neo.TransientError.Transaction.DeadlockDetected' + expect(errMessageStackLine1).to.contain neo4jStackTraceLine1.trim() + + +# +# Asserts that the given object is an instance of the appropriate Neo4j Error +# subclass, representing the given *old-style* Neo4j v1 error info. +# +# NOTE: This assumes this error is returned from an HTTP response. +# +exports.expectOldError = (err, statusCode, shortName, longName, message) -> + ErrorType = if statusCode >= 500 then 'Database' else 'Client' + exports._expectBaseError err, "#{ErrorType}Error" + + expect(err.message).to.equal "#{statusCode} [#{shortName}] #{message}" + + expect(err.neo4j).to.be.an 'object' + expect(err.neo4j).to.contain + exception: shortName + fullname: longName + message: message + + expect(err.neo4j.stacktrace).to.be.an 'array' + expect(err.neo4j.stacktrace).to.not.be.empty() + for line in err.neo4j.stacktrace + expect(line).to.be.a 'string' + expect(line).to.not.be.empty() + + +# +# Asserts that the given object is an instance of the appropriate Neo4j Error +# subclass, with the given raw message (which can be a fuzzy regex too). +# +# NOTE: This assumes no details info was returned by Neo4j. +# +exports.expectRawError = (err, classification, message) -> + exports._expectBaseError err, classification + + if typeof message is 'string' + expect(err.message).to.equal message + else if message instanceof RegExp + expect(err.message).to.match message + else + throw new Error "Unrecognized type of expected `message`: + #{typeof message} / #{message?.constructor.name}" + + expect(err.neo4j).to.be.empty() # TODO: Should this really be the case? + + +# +# Asserts that the given object is a simple HTTP error for the given response +# status code, e.g. 404 or 501. +# +exports.expectHttpError = (err, statusCode) -> + ErrorType = if statusCode >= 500 then 'Database' else 'Client' + statusText = http.STATUS_CODES[statusCode] # E.g. "Not Found" + + exports.expectRawError err, "#{ErrorType}Error", /// + ^ #{statusCode}\ #{statusText}\ response\ for\ [A-Z]+\ /.* $ + /// + + +# +# Returns a random string. +# +exports.getRandomStr = -> + "#{Math.random()}"[2..] diff --git a/test/mocha.opts b/test/mocha.opts index 653a2f1..165c6e0 100644 --- a/test/mocha.opts +++ b/test/mocha.opts @@ -1,5 +1,4 @@ --compilers coffee:coffee-script/register,_coffee:streamline/register ---ui exports --reporter spec ---timeout 10000 ---slow 1000 +--timeout 5000 +--slow 500