src/RethinkAdapter.js
// deps
import * as nrser from 'nrser';
const Promise = nrser.Promise;
const _ = nrser._;
import { Record, Adapter } from '@nrser/supermodel';
// package
import * as util from './util';
// types
import t from '@nrser/supermodel/lib/types';
import type {
Integer,
PositiveInteger,
} from 'nrser/lib/types/number';
import type {
Id,
Document
} from '@nrser/supermodel/lib/types';
import type {
IRethinkDb,
IConnection,
ITable,
} from './types';
/**
* Supermodel Adapter for RethinkDB.
*/
export class RethinkAdapter extends Adapter {
/**
* @param {rethinkdb} [rethinkdb]
* the module returned from require('rethinkdb').
*
* we need to pass it in so that we don't end up using a different
* instantiation than the one that created the connection.
*
* @param {rethinkdb.Net.Connection} [conn]
* connection to the rethink database.
*/
constructor(rethinkdb, conn) {
super();
this.r = rethinkdb;
this.conn = conn;
} // constructor
// rethink-specific utils
// ========================================================================
//
/**
* get the table name for a Record class; uses the tcomb type name.
*/
tableName(recordClass: Class<Record>): string {
return recordClass.meta.name;
}
/**
* get the rethink table for a Record class.
*/
table(recordClass: Class<Record>): RethinkTable {
return this.r.table(this.tableName(recordClass));
}
/**
* handle write (insert, replace) response from RethinkDB, which has an
* `errors` field with the number of write errors.
*
* convert it into a Promise rejection if present.
*/
handleWriteResponse(response): Promise<void> {
if (response.errors > 0) {
return Promise.reject(new Error(response.first_error));
}
return Promise.resolve();
} // .handleWriteResponse
/**
* ensure the DB is setup for the record classes.
*
* includes:
*
* 1. ensure the database exists.
* 2. for each Record class:
* 1. ensure the table exists.
* 2. ensure the indexes exist.
*/
ensureDBSetup(recordClasses: Array<Class<Record>>): Promise<void> {
return (
util.ensureDB(this.r, this.conn, this.conn.db)
.then(() => this.ensureTables(recordClasses))
.then(() => this.ensureIndexes(recordClasses))
.then(() => undefined) // return undefined
); // return
} // #ensureDBSetup
/**
* ensure tables for the Record classes have been created.
*
* returns a Promise of an object with table names mapped to whether the
* table had to be created or not.
*/
ensureTables(
recordClasses: Array<Class<Record>>,
): Promise<{[tableName: string]: boolean}> {
return (
Promise.props(
_.chain(recordClasses)
.map((recordClass) => {
const tableName = this.tableName(recordClass);
debug: `ensuring table ${ tableName }...`;
return [
tableName,
util.ensureTable(this.r, this.conn, tableName),
];
})
.fromPairs()
.value()
) // Promise.props
); // return
} // ensureTable
/**
* ensure all the indexes declared in the Record class exist, creating if
* necessary. if `wait` is true, uses `rethinkdb.indexWait` to wait for the
* index to be ready.
*
* returns a `Promise<void>` that resolves when all done, or rejects on error.
*/
ensureIndexes(
recordClasses: Class<Record> | Array<Class<Record>>,
{
wait = true,
} : {
wait: boolean,
} = {}
): Promise<
{[recordName: string]: {[indexName: string]: boolean}} |
{[indexName: string]: boolean}
> {
debug: "ensuring indexes...";
// if we were passed and array of Record classes, return a promise that
// maps their meta names to the results of ensuring the indexes on
// each one.
if (Array.isArray(recordClasses)) {
return (
Promise.props(
_.chain(recordClasses)
.map((recordClass) => {
return [
recordClass.meta.name,
this.ensureIndexes(recordClass, {wait})
];
})
.fromPairs()
.value() // end chain
) // Promise.props
); // return
} // if isArray
// now it should be a single record class
const recordClass: Class<Record> = recordClasses;
const tableName: string = this.tableName(recordClass);
debug: `ensuring indexes for ${ recordClass.meta.name }`;
// return a promise mapping index names to whether they were
// created or not
return (
Promise.props(
// take the index meta data
_.chain(recordClass.meta.indexes)
// pick the ones with truth-y values
.pickBy(
(shouldIndex: boolean, indexName: string) => !!shouldIndex
)
// get the keys, which are the index names
.keys()
// and map each to the ensureIndex promise
.map((indexName: string): [string, Promise<boolean>] => {
let promise = util.ensureIndex(
this.r,
this.conn,
tableName,
indexName
);
return [indexName, promise];
})
.fromPairs()
.value()
) // Promise.props
.tap((created: {[indexName: string]: boolean}) => {
debug: "indexes ENSURED", created;
if (wait) {
debug: `waiting for indexes on ${ tableName }...`;
return this.table(recordClass).indexWait().run(this.conn)
.then(() => {
debug: `indexes ready on ${ tableName }.`;
});
}
})
); // return
} // #ensureIndexes
// Adapter API implementations
// =========================================================================
//
/**
* @private
* @override
*/
_get(
recordClass: Class<Record>,
id: Id,
options: Object,
): Promise<Document> {
let query = this.table(recordClass).get(id);
if (options.join) {
query = query.merge(function(instance) {
return _.mapValues(options.join, ({recordClass, idProp}) => {
return this.table(recordClass).get(instance(idProp));
})
})
}
return query.run(this.conn);
}
/**
* @private
* @override
*/
_need(
recordClass: Class<Record>,
id: Id,
options: Object,
): Promise<Document> {
return this.get(recordClass, id, options)
.then((doc: ?Document) => {
if (doc === null) {
throw new nrser.NotFoundError(
`no ${ recordClass.name } found with id ${ id }`
);
}
return doc;
});
} // #_need
/**
* @private
* @override
*
* @param {Object} options
* @param {boolean} options.compact
* if true, the results should have keys with null values omitted.
*/
_getByIds(
recordClass: Class<Record>,
ids: Array<Id>,
options: Object,
): Promise<{[id: Id]: ?Record}> {
const compacted = this
.table(recordClass)
.getAll(...ids)
.run(this.conn)
.then((cursor) => cursor.toArray())
.then((docs) => {
return _.keyBy(docs, 'id');
});
if (options.compact) {
return compacted;
}
return compacted.then((byIds) => {
_.each(ids, (id) => {
if (!_.has(byIds, id)) {
byIds[id] = null;
}
});
return byIds;
});
} // #_getByIds
/**
* @private
* @override
*/
_needByIds(
recordClass: Class<Record>,
ids: Array<Id>,
options: Object = {},
): Promise<{[id: Id]: Record}> {
return this
// call _getByIds with compact=true so that it doesn't add null entries
._getByIds(recordClass, ids, {compact: true})
// then check if we got all of them
.then((byIds) => {
if (_.size(byIds) !== ids.length) {
// something's missing, throw up
const missingIds = _.difference(ids, _.keys(byIds));
throw nrser.NotFoundError.squish(`
could not find ${ recordClass.meta.name } with ids
${ missingIds }
`);
}
return byIds;
});
} // #_needByIds
/**
* @private
* @override
*/
_all(
recordClass: Class<Record>,
options: Object
): Promise<{[id: Id]: Document}> {
return this
.table(recordClass)
.run(this.conn)
.then((cursor) => {
return cursor.toArray();
})
.then((docs) => {
return _.keyBy(docs, 'id');
});
}
/**
* internal implementation of {#slice} called after arguments have been
* validated and normalized.
*/
_slice(
recordClass: Class<Record>,
{
start,
end,
}: {
start: Integer,
end?: Integer,
}
): Promise<Array<Document>> {
// supermodel.Adapter#slice takes care of argument validation and
// short-circuiting empty slices. when we get here we know:
//
// - end and limit are not both defined
// - if end is defined
// - it is an integer greater than start
// - end - start > 0
//
if (start < 0) {
// we're starting the slice at the end of the array, which rethink
// doesn't natively support, so we need to convert it into a slice from
// the beginning on a reverse-ordered cursor, then reverse the results.
// the reserve end is just the negation of the (negative) start (making
// it positive)
const reverseEnd = -1 * start;
// the reverse end is either 0 if no end was provided, or the negation
const reserveStart = t.Nil.is(end) ? 0 : -1 * end;
return this.table(recordClass)
.orderBy({index: this.r.desc('createdAt')})
.slice(reserveStart, reverseEnd)
.run(this.conn)
.then(cursor => cursor.toArray())
.then(docs => docs.reverse());
}
return this.table(recordClass)
.orderBy({index: this.r.asc('createdAt')})
.slice(start, end)
.run(this.conn)
.then((cursor) => {
return cursor.toArray();
});
} // #_slice
/**
* @private
* @override
*
* @param {Object} options
* for future use.
*/
_sample(
recordClass: Class<Record>,
number: PositiveInteger,
options: Object,
): Promise<Array<Document>> {
return this
.table(recordClass)
.sample(number)
.run(this.conn);
} // #_sample
/**
* insert a Record into the database.
*
* returns a new Record instance that reflects the changes (setting
* `updatedAt`).
*/
insert(
record: Record,
): Promise<Record> {
const data = record.makeObjectForWrite();
return this.r
.table(this.tableName(record.constructor))
.insert(data)
.run(this.conn)
.then(this.handleWriteResponse.bind(this))
.then(() => new record.constructor(data));
} // #insert
/**
* replace a Record in the database.
*
* returns a new Record instance that reflects the changes (setting
* `updatedAt`).
*/
replace(
record: Record,
): Promise<Record> {
const data = record.makeObjectForWrite();
return this.r
.table(this.tableName(record.constructor))
.get(record.id)
.replace(data)
.run(this.conn)
.then(this.handleWriteResponse.bind(this))
.then(() => new record.constructor(data));
} // #replace
/**
* internal driver-specific implementation of {#update} called after
* arguments have been validated and normalized.
*
* the updated Record instance is kept in {#update} and resolved from there;
* this method only need to resolve `void` on success or reject with an
* Error on failure.
*/
_update(
record: Record,
props: Object,
{
check = true,
} : {
check?: boolean,
} = {},
): Promise<void> {
let query = this.r
.table(this.tableName(record.constructor))
.get(record.id);
if (check) {
// we need to check that the rev hasn't changed from the version
// we are updating
query = query.update((data) => {
return this.r.branch(
data('rev').eq(record.rev),
props,
this.r.error("rev in database does not match record rev.")
);
});
} else {
// we don't care what rev is in the db, just blast it
query = query.update(props);
}
return query.run(this.conn)
.then(this.handleWriteResponse.bind(this));
} // #_update
// adaptations
// ===========
} // RethinkAdapter