Skip to content

Commit

Permalink
Introduce dedicated Record class
Browse files Browse the repository at this point in the history
Main point here is to add a small layer of indirection for accessing
fields in a record. Before this, we gave users a raw array instance
which is useful - but also means we can *never* add additional
functionality to a record, since we use up the "root" attribute space
with fields. Adding any additional attribute to the record would break
backwards compatibility.

This introduces a really frustrating wart in the API, where most other
access by key is done via JS object lookups (eg.
node.properties['blah']). However, records have both indexed and keyed
fields, meaning if a user ever did:

    RETURN 1, 0

It's now insane to figure out which value you get back when you ask for
record[0]. With this implemntation, there's a strict separation between
lookup by index (using JS number values) and lookup by key (using
String):

    get(0) -> 1
    get("0") -> 0

It also allows, as noted above, future extensions to the API, which the
original design made very cumbersome.
  • Loading branch information
jakewins committed Mar 17, 2016
1 parent 82c31c3 commit e2f512f
Show file tree
Hide file tree
Showing 11 changed files with 264 additions and 59 deletions.
16 changes: 14 additions & 2 deletions src/v1/internal/error.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,21 @@
// uniform across the driver surface.

function newError(message, code="N/A") {
return {message, code};
// TODO: Idea is that we can check the cod here and throw sub-classes
// of Neo4jError as appropriate
return new Neo4jError(message, code);
}

// TODO: This should be moved into public API
class Neo4jError extends Error {
constructor( message, code="N/A" ) {
super( message );
this.message = message;
this.code = code;
}
}

export {
newError
newError,
Neo4jError
}
29 changes: 18 additions & 11 deletions src/v1/internal/stream-observer.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
* limitations under the License.
*/

import {Record} from "../record";

/**
* Handles a RUN/PULL_ALL, or RUN/DISCARD_ALL requests, maps the responses
* in a way that a user-provided observer can see these as a clean Stream
Expand All @@ -32,7 +34,8 @@ class StreamObserver {
* @constructor
*/
constructor() {
this._head = null;
this._fieldKeys = null;
this._fieldLookup = null;
this._queuedRecords = [];
this._tail = null;
this._error = null;
Expand All @@ -45,24 +48,28 @@ class StreamObserver {
* @param {Array} rawRecord - An array with the raw record
*/
onNext(rawRecord) {
let record = {};
for (var i = 0; i < this._head.length; i++) {
record[this._head[i]] = rawRecord[i];
}
let record = new Record(this._fieldKeys, rawRecord, this._fieldLookup);
if( this._observer ) {
this._observer.onNext( record );
} else {
this._queuedRecords.push( record );
}
}

/**
* TODO
*/
onCompleted(meta) {
if( this._head === null ) {
// Stream header
this._head = meta.fields;
if( this._fieldKeys === null ) {
// Stream header, build a name->index field lookup table
// to be used by records. This is an optimization to make it
// faster to look up fields in a record by name, rather than by index.
// Since the records we get back via Bolt are just arrays of values.
this._fieldKeys = [];
this._fieldLookup = {};
if( meta.fields && meta.fields.length > 0 ) {
this._fieldKeys = meta.fields;
for (var i = 0; i < meta.fields.length; i++) {
this._fieldLookup[meta.fields[i]] = i;
}
}
} else {
// End of stream
if( this._observer ) {
Expand Down
105 changes: 105 additions & 0 deletions src/v1/record.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/**
* Copyright (c) 2002-2016 "Neo Technology,"
* Network Engine for Objects in Lund AB [http://neotechnology.com]
*
* This file is part of Neo4j.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import {newError} from "./internal/error";

function generateFieldLookup( keys ) {
let lookup = {};
keys.forEach( (name, idx) => { lookup[name] = idx; });
return lookup;
}

/**
* Records make up the contents of the {@link Result}, and is how you access
* the output of a statement. A simple statement might yield a result stream
* with a single record, for instance:
*
* MATCH (u:User) RETURN u.name, u.age
*
* This returns a stream of records with two fields, named `u.name` and `u.age`,
* each record represents one user found by the statement above. You can access
* the values of each field either by name:
*
* record.get("n.name")
*
* Or by it's position:
*
* record.get(0)
*
* @access public
*/
class Record {
/**
* Create a new record object.
* @constructor
* @access private
* @param {Object} keys An array of field keys, in the order the fields appear
* in the record
* @param {Object} fields An array of field values
* @param {Object} fieldLookup An object of fieldName -> value index, used to map
* field names to values. If this is null, one will be
* generated.
*/
constructor(keys, fields, fieldLookup=null ) {
this.keys = keys;
this.length = keys.length;
this._fields = fields;
this._fieldLookup = fieldLookup || generateFieldLookup( keys );
}

/**
* Run the given function for each field in this record. The function
* will get three arguments - the value, the key and this record, in that
* order.
*
* @param visitor
*/
forEach( visitor ) {
for(let i=0;i<this.keys.length;i++) {
visitor( this._fields[i], this.keys[i], this );
}
}

/**
* Get a value from this record, either by index or by field key.
*
* @param {string|Number} key Field key, or the index of the field.
* @returns {*}
*/
get( key ) {
let index;
if( !(typeof key === "number") ) {
index = this._fieldLookup[key];
if( index === undefined ) {
throw newError("This record has no field with key '"+key+"', available key are: [" + this.keys + "].");
}
} else {
index = key;
}

if( index > this._fields.length - 1 || index < 0 ) {
throw newError("This record has no field with index '"+index+"'. Remember that indexes start at `0`, " +
"and make sure your statement returns records in the shape you meant it to.");
}

return this._fields[index];
}
}

export {Record}
25 changes: 14 additions & 11 deletions src/v1/result.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,14 @@ import {polyfill as polyfillPromise} from '../external/es6-promise';
polyfillPromise();

/**
* A Result instance is used for retrieving request response.
* A stream of {@link Record} representing the result of a statement.
* @access public
*/
class Result {
/**
* Inject the observer to be used.
* @constructor
* @access private
* @param {StreamObserver} streamObserver
* @param {mixed} statement - Cypher statement to execute
* @param {Object} parameters - Map with parameters to use in statement
Expand Down Expand Up @@ -65,11 +66,11 @@ class Result {
}

/**
* Waits for all results and calls the passed in function
* with the results.
* Cannot be used with the subscribe function.
* @param {function(error: Object)} onFulfilled - Function to be called when finished.
* @param {function(error: Object)} onRejected - Function to be called upon errors.
* Waits for all results and calls the passed in function with the results.
* Cannot be combined with the {@link #subscribe} function.
*
* @param {function(result: {records:Array<Record>})} onFulfilled - Function to be called when finished.
* @param {function(error: {message:string, code:string})} onRejected - Function to be called upon errors.
* @return {Promise} promise.
*/
then(onFulfilled, onRejected) {
Expand All @@ -80,7 +81,7 @@ class Result {
/**
* Catch errors when using promises.
* Cannot be used with the subscribe function.
* @param {function(error: Object)} onRejected - Function to be called upon errors.
* @param {function(error: {message:string, code:string})} onRejected - Function to be called upon errors.
* @return {Promise} promise.
*/
catch(onRejected) {
Expand All @@ -89,11 +90,13 @@ class Result {
}

/**
* Stream results to observer as they come in.
* Stream records to observer as they come in, this is a more efficient method
* of handling the results, and allows you to handle arbitrarily large results.
*
* @param {Object} observer - Observer object
* @param {function(record: Object)} observer.onNext - Handle records, one by one.
* @param {function(metadata: Object)} observer.onComplete - Handle stream tail, the metadata.
* @param {function(error: Object)} observer.onError - Handle errors.
* @param {function(record: Record)} observer.onNext - Handle records, one by one.
* @param {function(metadata: Object)} observer.onCompleted - Handle stream tail, the metadata.
* @param {function(error: {message:string, code:string})} observer.onError - Handle errors.
* @return
*/
subscribe(observer) {
Expand Down
2 changes: 1 addition & 1 deletion test/v1/examples.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ describe('examples', function() {
session
.run( "MATCH (p:Person) WHERE p.name = 'Neo' RETURN p.age" )
.then( function( result ) {
console.log( "Neo is " + result.records[0]["p.age"].toInt() + " years old." );
console.log( "Neo is " + result.records[0].get("p.age").toInt() + " years old." );

session.close();
driver.close();
Expand Down
80 changes: 80 additions & 0 deletions test/v1/record.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/**
* Copyright (c) 2002-2016 "Neo Technology,"
* Network Engine for Objects in Lund AB [http://neotechnology.com]
*
* This file is part of Neo4j.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

var Record = require("../../lib/v1/record").Record;
var Neo4jError = require("../../lib/v1/internal/error").Neo4jError;


describe('Record', function() {
it('should allow getting fields by name', function() {
// Given
var record = new Record( ["name"], ["Bob"] );

// When & Then
expect(record.get("name")).toEqual("Bob");
});

it('should give helpful error on no such key', function() {
// Given
var record = new Record( ["name"], ["Bob"] );

// When & Then
expect( function() { record.get("age") }).toThrow(new Neo4jError(
"This record has no field with key 'age', available key are: [name]."));
});

it('should allow getting fields by index', function() {
// Given
var record = new Record( ["name"], ["Bob"] );

// When & Then
expect(record.get(0)).toEqual("Bob");
});

it('should give helpful error on no such index', function() {
// Given
var record = new Record( ["name"], ["Bob"] );

// When & Then
expect( function() { record.get(1) }).toThrow(new Neo4jError(
"This record has no field with index '1'. Remember that indexes start at `0`, " +
"and make sure your statement returns records in the shape you meant it to."));
});

it('should have length', function() {
// When & Then
expect( new Record( [], []).length ).toBe(0);
expect( new Record( ["name"], ["Bob"]).length ).toBe(1);
expect( new Record( ["name", "age"], ["Bob", 45]).length ).toBe(2);
});

it('should allow forEach through the record', function() {
// Given
var record = new Record( ["name", "age"], ["Bob", 45] );
var result = [];

// When
record.forEach( function( value, key, record ) {
result.push( [value, key, record] );
});

// Then
expect(result).toEqual([["Bob", "name", record], [45, "age", record]]);
});
});
8 changes: 4 additions & 4 deletions test/v1/session.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ describe('session', function() {
},
onCompleted : function( ) {
expect( records.length ).toBe( 1 );
expect( records[0]['a'] ).toBe( 1 );
expect( records[0].get('a') ).toBe( 1 );
done();
}
});
Expand Down Expand Up @@ -95,7 +95,7 @@ describe('session', function() {
},
onCompleted : function( ) {
expect( records.length ).toBe( 1 );
expect( records[0]['a'] ).toBe( true );
expect( records[0].get('a') ).toBe( true );
done();
}
});
Expand All @@ -107,13 +107,13 @@ describe('session', function() {
.then(
function(result) {
expect(result.records.length).toBe( 1 );
expect(result.records[0]['a']).toBe( 1 );
expect(result.records[0].get('a')).toBe( 1 );
return result
}
).then(
function(result) {
expect(result.records.length).toBe( 1 );
expect(result.records[0]['a']).toBe( 1 );
expect(result.records[0].get('a')).toBe( 1 );
}
).then( done );
});
Expand Down
Loading

0 comments on commit e2f512f

Please sign in to comment.