tabel
Version:
A simple orm for PostgreSQL which works with simple javascript objects and arrays
1,632 lines (1,455 loc) • 47.9 kB
JavaScript
/*
{
// the table's name, is required
name: null,
// table properties
props: {
key: 'id',
// default key column, can be ['user_id', 'post_id'] for composite keys
uuid: false,
// by default we don't assume that you use an auto generated db id
perPage: 25,
// standard batch size per page used by `forPage` method
// forPage method uses offset
// avoid that and use a keyset in prod (http://use-the-index-luke.com/no-offset)
timestamps: false
// set to `true` if you want auto timestamps or
// timestamps: ['created_at', 'updated_at'] (these are defaults when `true`)
// will be assigned in this order only
},
// predefined scopes on the table
scopes: {},
// predefined joints on the table
joints: {},
// relations definitions for the table
relations: {},
// table methods defintions
methods: {}
}
*/
const md5 = require('md5');
const uuid = require('uuid');
const isUsableObject = require('isusableobject');
const {
isArray,
isString,
isDate,
isNumber,
isFunction,
assign,
merge,
toPlainObject
} = require('lodash');
const Scope = require('./Scope');
const Scoper = require('./Scoper');
const Track = require('./Track');
const HasOne = require('./relations/HasOne');
const HasMany = require('./relations/HasMany');
const HasManyThrough = require('./relations/HasManyThrough');
const BelongsTo = require('./relations/BelongsTo');
const ManyToMany = require('./relations/ManyToMany');
const MorphOne = require('./relations/MorphOne');
const MorphMany = require('./relations/MorphMany');
const MorphTo = require('./relations/MorphTo');
class Table {
constructor(orm) {
this.orm = orm;
this.scopeTrack = new Track();
if (this.orm.cache) {
this.cache = this.orm.cache.hash(`tabel__${this.tableName()}`);
}
}
/**
* get the tablename
* @return {string} the tableName
*/
tableName() {
return this.name;
}
/**
* knex.raw helper
* @param {string} expr raw expression
* @param {Array} bindings bindings for the expression
* @return {knex.raw} raw expr
*/
raw(expr, bindings=[]) {
return this.orm.raw(expr, bindings);
}
/**
* just return a raw knex query for this table
* @return {knex.query} a fresh knex query for table
*/
rawQuery() {
return this.orm.knex(this.tableName());
}
/**
* get a new query instance for this table, with a few flags
* set on the query object used by the orm
* @return {knex.query} a fresh knex query for table with orm flags
*/
newQuery() {
return this.attachOrmNSToQuery(
this.orm.knex(this.tableName())
);
}
/**
* attach _orm to knex query with required flags
* @param {knex.query} q knex query
* @return {knex.query} query with namespace
*/
attachOrmNSToQuery(q) {
q._orm = {
// used by cache processor
cacheEnabled: false,
cacheLifetime: null,
// transaction being used on the query
trx: null,
// relations to be eagerloaded on the query
eagerLoads: {},
// map to be applied to the query result
maps: []
};
return q;
}
/**
* get a fully scoped query, with flags for whether this is a count query or not.
* the counting function sets columns it counts on smartly
* @return {knex.query} table's query object with scopeTrack applied
*/
query() {
const q = this.newQuery();
// apply the scopeTrack on the query
this.scopeTrack.apply(q);
if (this.scopeTrack.hasScope('select')) {
return q;
} else {
return q.select(this.c('*'));
}
}
/**
* load columns of the table
* @return {Promise} a promise which resolves when table columns have loaded
*/
load() {
return this.newQuery().columnInfo().then((columns) => {
this.orm.tableColumns.set(this.tableName(), columns);
return this;
});
}
/**
* get a promise of columns in the table
* @return {Promise} promise containing array of table's columnNames
*/
columns() {
if (this.orm.tableColumns.has(this.tableName())) {
return Promise.resolve(
Object.keys(this.orm.tableColumns.get(this.tableName()))
);
} else {
return this.load().then(() => this.columns());
}
}
/**
* qualified column name helper
* @param {mixed} col column or [columns]
* @return {mixed} qualified column name(s)
*/
c(col) {
if (isArray(col)) {
return col.map((c) => this.c(c));
}
if (isString(col)) {
return col.indexOf('.') > -1 ? col : `${this.tableName()}.${col}`;
}
return col;
}
/**
* get the key of the table
* @return {mixed} get the key property
*/
key() {
return this.props.key;
}
/**
* qualified column of key(s)
* @return {mixed} qualified key column(s)
*/
keyCol() {
return this.c(this.key());
}
/**
* chain new scopes to the table's scopeTrack
* @param {function} closure op to be applied on the query
* @param {string} label label of the scope, optional
* @return {this} current instance
*/
scope(closure, label='scope') {
this.scopeTrack.push(new Scope(closure, label));
return this;
}
/**
* chain a new joint to the table's scopeTrack.
* will be run only once, if another with same label has run before
* @param {function} closure op to be applied on the query
* @param {string} label label of the joint, optional
* @return {this} current instance
*/
joint(closure, label='joint') {
this.scopeTrack.push(new Scope(closure, label, true));
return this;
}
/**
* fork the table and its scopes so that different scopes can be applied
* to both instances further
* @return {this.constructor} forked table instance
*/
fork() {
const forkedTable = new this.constructor(this.orm);
forkedTable.scopeTrack = this.scopeTrack.fork();
return forkedTable;
}
/**
* fork the table free of scopes
* @return {this.constructor} forked and clean table instance
*/
fresh() {
return new this.constructor(this.orm);
}
/**
* helper to refer to other tables. carries over transaction
* and settings
* @param {string} tableName name of table you want
* @return {Table} table instance for tableName
*/
table(tableName) {
const q = this.query();
const tbl = this.orm.table(tableName);
if (q._orm.trx !== null) {
tbl.transacting(q._orm.trx);
}
return tbl;
}
/**
* shorthand for table
* @param {string} tableName name of table you want
* @return {Table} table instance for tableName
*/
tbl(tableName) {
return this.table(tableName);
}
/**
* don't scope any rows
* @return {this} current instance
*/
whereFalse() {
return this.scope((q) => q.whereRaw('?', [false]), 'whereFalse');
}
/**
* apply a where condition on the key(s) with scopes as planned
* @param {mixed} val value(s) to match the key(s) against
* @return {this} current instance
*
* whereKey(1)
* whereKey({id: 1})
* whereKey({post_id: 1, tag_id: 2})
* whereKey([1,2,3,4]);
* whereKey([{post_id: 1, tag_id: 2}, {post_id: 1, tag_id:2}])
*/
whereKey(val) {
if (isArray(val)) {
return this.whereIn(this.key(), val);
} else {
if (isArray(this.key())) {
if (isUsableObject(val)) {
val = toPlainObject(val);
return this.where(this.key().reduce((conditions, k) => {
return assign(conditions, {[k]: val[k]});
}, {}));
} else {
return this.where(this.key().reduce((conditions, k) => {
return assign(conditions, {[k]: val});
}, {}));
}
} else {
return this.where({[this.key()]: val});
}
}
}
/**
* apply an orWhere condition on the key(s) with scopes as planned
* @param {mixed} val value(s) to match the key(s) against
* @return {this} current instance
*
* orWhereKey(1)
* orWhereKey({id: 1})
* orWhereKey({post_id: 1, tag_id: 2})
* orWhereKey([1,2,3,4]);
* orWhereKey([{post_id: 1, tag_id: 2}, {post_id: 1, tag_id:2}])
*/
orWhereKey(val) {
if (isArray(val)) {
return this.orWhereIn(this.key(), val);
} else {
if (isArray(this.key())) {
if (isUsableObject(val)) {
val = toPlainObject(val);
return this.orWhere(this.key().reduce((conditions, k) => {
return assign(conditions, {[k]: val[k]});
}, {}));
} else {
return this.orWhere(this.key().reduce((conditions, k) => {
return assign(conditions, {[k]: val});
}, {}));
}
} else {
return this.orWhere({[this.key()]: val});
}
}
}
/**
* scope a where condition
* @param {mixed} args conditions
* @return {this} current instance
*/
where(...args) {
if (args.length === 1) {
if (isUsableObject(args[0])) {
const conditions = toPlainObject(args[0]);
return Object.keys(conditions).reduce(
(table, field) => table.where(field, '=', conditions[field]),
this
);
} else if(isFunction(args[0])) {
return this.scope((q) => q.where(args[0]), 'where');
}
}
if (args.length === 2) {
const [field, val] = args;
return this.where(field, '=', val);
}
if (args.length === 3) {
const [field, op, val] = args;
switch (op.toLowerCase()) {
case 'in': return this.whereIn(field, val);
case 'not in': return this.whereNotIn(field, val);
case 'between': return this.whereBetween(field, val);
case 'not between': return this.whereNotBetween(field, val);
default: return this.scope((q) => q.where(this.c(field), op, val), 'where');
}
}
return this;
}
/**
* scope an orWhere condition
* @param {mixed} args conditions
* @return {this} current instance
*/
orWhere(...args) {
if (args.length === 1) {
if (isUsableObject(args[0])) {
const conditions = toPlainObject(args[0]);
return Object.keys(conditions).reduce(
(table, field) => table.orWhere(field, '=', conditions[field]),
this
);
} else if (isFunction(args[0])) {
return this.scope((q) => q.orWhere(args[0]), 'where');
}
}
if (args.length === 2) {
const [field, val] = args;
return this.where(field, '=', val);
}
if (args.length === 3) {
const [field, op, val] = args;
switch (op.toLowerCase()) {
case 'in': return this.orWhereIn(field, val);
case 'not in': return this.orWhereNotIn(field, val);
case 'between': return this.orWhereBetween(field, val);
case 'not between': return this.orWhereNotBetween(field, val);
default: return this.scope((q) => q.orWhere(this.c(field), op, val), 'orWhere');
}
}
return this;
}
/**
* scope a whereNot condition
* @param {mixed} args conditions
* @return {this} current instance
*/
whereNot(...args) {
if (args.length === 1) {
if (isUsableObject(args[0])) {
const conditions = toPlainObject(args[0]);
return Object.keys(conditions).reduce(
(table, field) => table.whereNot(field, '=', conditions[field]),
this
);
} else if (isFunction(args[0])) {
return this.scope((q) => q.whereNot(args[0]), 'whereNot');
}
}
if (args.length === 2) {
const [field, val] = args;
return this.whereNot(field, '=', val);
}
if (args.length === 3) {
const [field, op, val] = args;
switch (op.toLowerCase()) {
case 'in': return this.whereNotIn(field, val);
case 'not in': return this.whereIn(field, val);
case 'between': return this.whereNotBetween(field, val);
case 'not between': return this.whereBetween(field, val);
default: return this.scope((q) => q.whereNot(this.c(field), op, val), 'whereNot');
}
}
return this;
}
/**
* scope an orWhereNot condition
* @param {mixed} args conditions
* @return {this} current instance
*/
orWhereNot(...args) {
if (args.length === 1) {
if (isUsableObject(args[0])) {
const conditions = toPlainObject(args[0]);
return Object.keys(conditions).reduce(
(table, field) => table.orWhereNot(field, '=', conditions[field]),
this
);
} else if (isFunction(args[0])) {
return this.scope((q) => q.orWhereNot(args[0]), 'orWhereNot');
}
}
if (args.length === 2) {
const [field, val] = args;
return this.orWhereNot(field, '=', val);
}
if (args.length === 3) {
const [field, op, val] = args;
switch (op.toLowerCase()) {
case 'in': return this.orWhereNotIn(field, val);
case 'not in': return this.orWhereIn(field, val);
case 'between': return this.orWereNotBetween(field, val);
case 'not between': return this.orWhereBetween(field, val);
default: return this.scope((q) => q.orWhereNot(this.c(field), op, val), 'orWhere');
}
}
return this;
}
/**
* scope a whereIn condition
* @param {string|[string]} field field name
* @param {[mixed]} vals values to match against
* @return {this} current instance
*
* whereIn('id', [1,2,3,4])
* whereIn(['user_id', 'post_id'], [{user_id: 1, post_id: 2}, {user_id: 3, post_id: 5}])
*/
whereIn(field, vals=[]) {
if (vals.length === 0) {
return this.whereFalse();
} else {
if (isArray(field)) {
return this.whereRaw(
`(${this.c(field).join(',')}) in (${vals.map(() => `(${field.map(() => '?').join(',')})`).join(',')})`,
vals.map((v) => field.map((f) => v[f])).reduce((all, item) => all.concat(item), [])
);
} else {
return this.scope((q) => q.whereIn(this.c(field), vals), 'whereIn');
}
}
}
/**
* scope an orWhereIn condition
* @param {string|[string]} field field name
* @param {[mixed]} vals values to match against
* @return {this} current instance
*
* orWhereIn('id', [1,2,3,4])
* orWhereIn(['user_id', 'post_id'], [{user_id: 1, post_id: 2}, {user_id: 3, post_id: 5}])
*
*/
orWhereIn(field, vals=[]) {
if (vals.length === 0) {
return this;
} else {
if (isArray(field)) {
return this.orWhereRaw(
`(${this.c(field).join(',')}) in (${vals.map(() => `(${field.map(() => '?').join(',')})`).join(',')})`,
vals.map((v) => field.map((f) => v[f])).reduce((all, item) => all.concat(item), [])
);
} else {
return this.scope((q) => q.orWhereIn(this.c(field), vals), 'orWhereIn');
}
}
}
/**
* scope a whereNotIn condition
* @param {string|[string]} field field name
* @param {[mixed]} vals values to match against
* @return {this} current instance
*/
whereNotIn(field, vals=[]) {
if (vals.length === 0) {
return this;
} else {
if (isArray(field)) {
return this.whereRaw(
`(${this.c(field).join(',')}) not in (${vals.map(() => `(${field.map(() => '?').join(',')})`).join(',')})`,
vals.map((v) => field.map((f) => v[f])).reduce((all, item) => all.concat(item), [])
);
} else {
return this.scope((q) => q.whereNotIn(this.c(field), vals), 'whereNotIn');
}
}
}
/**
* scope a whereNotIn condition
* @param {string|[string]} field field name
* @param {[mixed]} vals values to match against
* @return {this} current instance
*/
orWhereNotIn(field, vals=[]) {
if (vals.length === 0) {
return this;
} else {
if (isArray(field)) {
return this.orWhereRaw(
`(${this.c(field).join(',')}) not in (${vals.map(() => `(${field.map(() => '?').join(',')})`).join(',')})`,
vals.map((v) => field.map((f) => v[f])).reduce((all, item) => all.concat(item), [])
);
} else {
return this.scope((q) => q.orWhereNotIn(this.c(field), vals), 'orWhereNotIn');
}
}
}
/**
* scope a whereNull condition
* @param {string} field field name
* @return {this} current instance
*/
whereNull(field) {
return this.scope((q) => q.whereNull(this.c(field)), 'whereNull');
}
/**
* scope an orWhereNull condition
* @param {string} field field name
* @return {this} current instance
*/
orWhereNull(field) {
return this.scope((q) => q.orWhereNull(this.c(field)), 'orWhereNull');
}
/**
* scope a whereNotNull condition
* @param {string} field field name
* @return {this} current instance
*/
whereNotNull(field) {
return this.scope((q) => q.whereNotNull(this.c(field)), 'whereNotNull');
}
/**
* scope an orWhereNotNull condition
* @param {string} field field name
* @return {this} current instance
*/
orWhereNotNull(field) {
return this.scope((q) => q.whereNotNull(this.c(field)), 'orWhereNotNull');
}
/**
* scope a whereBetween condition
* @param {string} field field name
* @param {[mixed]} range range of vals
* @return {this} current instance
*/
whereBetween(field, [min, max]) {
return this.scope((q) => q.whereBetween(this.c(field), [min, max]), 'whereBetween');
}
/**
* scope a orWhereBetween condition
* @param {string} field field name
* @param {[mixed]} range range of vals
* @return {this} current instance
*/
orWhereBetween(field, [min, max]) {
return this.scope((q) => q.orWhereBetween(this.c(field), [min, max]), 'orWhereBetween');
}
/**
* scope a whereNotBetween condition
* @param {string} field field name
* @param {[mixed]} range range of vals
* @return {this} current instance
*/
whereNotBetween(field, [min, max]) {
return this.scope((q) => q.whereNotBetween(this.c(field), [min, max]), 'whereNotBetween');
}
/**
* scope a orWhereNotBetween condition
* @param {string} field field name
* @param {[mixed]} range range of vals
* @return {this} current instance
*/
orWhereNotBetween(field, [min, max]) {
return this.scope((q) => q.orWhereNotBetween(this.c(field), [min, max]), 'orWhereNotBetween');
}
/**
* scope a whereRaw condition
* @param {string} condition raw where condition
* @param {[mixed]} bindings condition bindings
* @return {this} current instance
*/
whereRaw(condition, bindings) {
return this.scope((q) => q.whereRaw(condition, bindings), 'whereRaw');
}
/**
* scope a orWhereRaw condition
* @param {string} condition raw where condition
* @param {[mixed]} bindings condition bindings
* @return {this} current instance
*/
orWhereRaw(condition, bindings) {
return this.scope((q) => q.orWhereRaw(condition, bindings), 'orWhereRaw');
}
/**
* scope a transaction
* @param {knex.transaction} trx the ongoing transaction
* @return {this} current instance
*/
transacting(trx) {
return this.scope((q) => { q._orm.trx = trx; q.transacting(trx); }, 'transacting');
}
/**
* scope for a page number
* @param {int} page page number
* @param {int} perPage records per page
* @return {this} current instance
*/
forPage(page, perPage) {
page = parseInt(page, 10);
page = page < 1 ? 1 : page;
perPage = (isNumber(perPage) && perPage > 0) ? perPage : this.props.perPage;
const [limit, offset] = [perPage, ((page - 1) * perPage)];
return this.limit(limit).offset(offset);
}
/**
* apply a scope which sets an offset
* @param {int} offset offset to be set on the query
* @return {this} current instance
*/
offset(offset) {
return this.scope((q) => q.offset(offset), 'offset');
}
/**
* apply a scope which sets a limit on the query
* @param {int} limit limit to be set on the query
* @return {this} current instance
*/
limit(limit) {
return this.scope((q) => q.limit(limit), 'limit');
}
/**
* apply a scope which sets an order on the query
* @param {string} field column by which to order
* @param {string} direction should be 'asc', 'desc'
* @return {this} current instance
*/
orderBy(field, direction) {
return this.scope((q) => q.orderBy(this.c(field), direction), 'orderBy');
}
/**
* apply a scope which sets an orderByRaw on the query
* @param {string} sql sql for the order by
* @param {array} bindings bindings for orderByRaw
* @return {this} current instance
*/
orderByRaw(sql, bindings) {
return this.scope((q) => q.orderByRaw(sql, bindings), 'orderByRaw');
}
/**
* apply a scope which sets a groupBy on the query
* @param {...string} args columns to group by
* @return {this} current instance
*/
groupBy(...args) {
return this.scope((q) => q.groupBy(...this.c(args)), 'groupBy');
}
/**
* apply a scope which sets a groupByRaw on the query
* @param {string} sql sql for the group by
* @param {array} bindings bindings for groupBy
* @return {this} current instance
*/
groupByRaw(sql, bindings) {
return this.scope((q) => q.groupByRaw(sql, bindings), 'groupByRaw');
}
/**
* apply a scope which sets a having clause on the query
* @param {string} col column
* @param {op} op operator
* @param {val} val value
* @return {this} current instance
*/
having(col, op, val) {
return this.scope((q) => q.having(col, op, val), 'having');
}
/**
* apply a scope which sets a having clause on the query
* @param {string} sql sql string for the having clause
* @param {array} bindings bindings for the sql
* @return {this} current instance
*/
havingRaw(sql, bindings) {
return this.scope((q) => q.havingRaw(sql, bindings), 'havingRaw');
}
/**
* apply a scope which sets a distinct clause on the query
* @return {this} current instance
*/
distinct() {
return this.scope((q) => q.distinct(), 'distinct');
}
/**
* apply a scope to select some columns
* @param {mixed} cols the columns to select
* @return {this} current instance
*/
select(...cols) {
return this.scope((q) => { q.select(this.c(cols)); }, 'select');
}
/**
* apply a scope to join a table with this Table
* @param {string} tableName to join
* @param {...mixed} args join conditions
* @return {this} current instance
*/
join(tableName, ...args) {
if (isFunction(args[0])) {
const joiner = args[0];
return this.joint((q) => { q.join(tableName, joiner); }, `join${tableName}${md5(joiner.toString())}`);
} else {
return this.joint((q) => { q.join(tableName, ...args); }, `join${tableName}${md5(args.toString())}`);
}
}
/**
* apply a scope to leftJoin a table with this Table
* @param {string} tableName to join
* @param {...mixed} args join conditions
* @return {this} current instance
*/
leftJoin(tableName, ...args) {
if (isFunction(args[0])) {
const joiner = args[0];
return this.joint((q) => { q.leftJoin(tableName, joiner); }, `join${tableName}${md5(joiner.toString())}`);
} else {
return this.joint((q) => { q.leftJoin(tableName, ...args); }, `join${tableName}${md5(args.toString())}`);
}
}
/**
* apply a scope which enables a cache on the current query
* @param {int} lifetime lifetime in milliseconds
* @return {this} current instance
*/
remember(lifetime) {
return this.scope((q) => {
q._orm.cacheEnabled = true;
q._orm.cacheLifetime = lifetime;
}, 'cache');
}
/**
* apply a debug scope on the query
* @param {Boolean} flag true/false
* @return {this} current instance
*/
debug(flag=true) {
return this.scope((q) => q.debug(flag));
}
/**
* add a scope to eager-load various relations
* @param {...mixed} eagerLoads relations to eager-load with constraints
* @return {this} current instance
*/
eagerLoad(...eagerLoads) {
eagerLoads = this.parseEagerLoads(eagerLoads);
return this.scope((q) => { assign(q._orm.eagerLoads, eagerLoads); }, 'eagerLoad');
}
/**
* parse and eagerLoads argument
* @param {mixed} eagerLoads eagerLoads to be parsed, {} or []
* @return {this} current instance
*/
parseEagerLoads(eagerLoads) {
// if eagerLoads is of the form ['foo', 'foo.bar', {'foo.baz': (t) => { t.where('active', true); }}]
// then use a place-holder constraint for 'foo' & 'foo.bar'
// and reduce to form {'rel1': constraint1, 'rel2': constraint2}
if (isArray(eagerLoads)) {
return this.parseEagerLoads(
eagerLoads.map((eagerLoad) => {
if (isUsableObject(eagerLoad)) {
return toPlainObject(eagerLoad);
} else {
return {[eagerLoad]: () => {}};
}
}).reduce((eagerLoadsObject, eagerLoad) => {
return assign(eagerLoadsObject, eagerLoad);
}, {})
);
}
// processing the object form of eagerLoads
return Object.keys(eagerLoads)
.reduce((allRelations, relation) => {
if (relation.indexOf('.') === -1) {
return allRelations.concat([relation]);
} else {
// foo.bar.baz
// ->
// foo
// foo.bar
// foo.bar.baz
return allRelations.concat(
relation.split('.').reduce((relationParts, part) => {
return relationParts.concat([relationParts.slice(-1).concat([part]).join('.')]);
}, [])
);
}
}, [])
.reduce((parsedEagerLoads, relation) => {
if (relation in eagerLoads) {
return assign(parsedEagerLoads, {[relation]: eagerLoads[relation]});
} else {
return assign(parsedEagerLoads, {[relation]: () => {}});
}
}, {})
;
}
/**
* clear table's cache
* @return {Promise} promise for clearing table's cache
*/
clearCache() {
return this.cache.clear().then(() => this);
}
/**
* uncache the query chain
* @param {options} options whether we are forgetting count or rows
* @return {Promise} current instance
*/
forget(options={count: false}) {
const q = options.count ? this.countQuery() : this.query();
const key = this.queryCacheKey(q);
return this.cache.del(key).then(() => this);
}
/**
* process the result of a query, strip table's name,
* replace '.' with '__' in columns with different table-prefix,
* parse count if the query is a count query
* @param {mixed} result result fetched for the query
* @param {options} options whether we are fetching count results
* @return {mixed} the processed result
*/
processResult(result, options) {
const {count} = isUsableObject(options) ? toPlainObject(options) : {count: false};
if (count === true) {
// result[0].count is how knex gives count query results
if (isPostgres(this.orm)) {
return parseInt(result[0].count, 10);
} else if (isMysql(this.orm)) {
return result[0]['count(*)'];
} else {
throw new UnsupportedDbError;
}
} else if (isArray(result)) {
// processing an array of response
return result.map((row) => this.processResult(row, {count}));
} else if (isUsableObject(result)) {
// processing individual model results
result = toPlainObject(result);
return Object.keys(result).reduce((processed, key) => {
if (key.indexOf('.') > -1) {
if (key.indexOf(this.tableName()) === 0) {
return assign(processed, {[key.split('.')[1]]: result[key]});
} else {
return assign(processed, {[key.split('.').join('__')]: result[key]});
}
} else {
return assign(processed, {[key]: result[key]});
}
}, {});
} else {
// processing other random values
return result;
}
}
/**
* eager load relations for an array of models
* @param {array} models of models
* @param {object} eagerLoads a processed eagerLoads with constraints
* @return {Promise} promise which resilves when all relations have loaded
*/
loadRelations(models, eagerLoads) {
if ((isArray(models) && models.length === 0)) {
// don't do anything for empty values
return Promise.resolve(models);
}
return Promise.all(Object.keys(eagerLoads)
// filter all top level relations
.filter((relation) => relation.indexOf('.') === -1)
.map((relation) => {
// check for the relation actually being there
if (!this.definedRelations.has(relation)) {
throw new Error(`invalid relation ${relation}`);
}
return this[relation]()
.eagerLoad(this.subEagerLoads(relation, eagerLoads))
.constrain(eagerLoads[relation])
.load(models)
;
})
).then((results) => {
return results.reduce((mergedResult, result) => {
return merge(mergedResult, result);
}, models);
});
}
/**
* get the subEagerLoads for a relation, given a set of eagerLoads
* @param {string} relation name of the relation
* @param {object} eagerLoads {relationName: cosntraint} form eagerLoads
* @return {object} subEagerLoads of relation with constraints
*/
subEagerLoads(relation, eagerLoads) {
return Object.keys(eagerLoads)
.filter((relationName) => {
return relationName.indexOf(relation) === 0 && relationName !== relation;
})
.map((relationName) => {
return relationName.split('.').slice(1).join('.');
})
.reduce((subEagerLoads, subRelationName) => {
return assign(subEagerLoads, {
[subRelationName]: eagerLoads[`${relation}.${subRelationName}`]
});
}, {})
;
}
/**
* returns key which will be used to cache a query's result
* @param {knex.query} q the query which is being used to fetch result
* @return {string} md5 hash of the query
*/
queryCacheKey(q) {
const queryStr = q.toString();
const eagerLoadsStr = Object.keys(q._orm.eagerLoads)
.map((rel) => ({rel, constraint: q._orm.eagerLoads[rel]}))
.slice(0).sort((a, b) => {
return a.rel < b.rel ? -1 : 1;
})
.map(({rel, constraint}) => {
return `${rel}:${constraint.toString()}`;
})
.join('-')
;
return [queryStr, eagerLoadsStr].map((s) => md5(s)).join('-');
}
/**
* get the first row for the scoped query
* @param {...mixed} args conditions for scoping the query
* @return {Promise} promise which resolves the result
*/
first(...args) {
return this.limit(1).all(...args).then((result) => (
result.length > 0 ? result[0] : null
));
}
/**
* get all rows from the scoped query
* @param {...mixed} args conditions for scoping the query
* @return {Promise} promise which resolves the result
*/
all(...args) {
if (args.length === 1) {
return this.where(args[0]).all();
} else if (args.length >= 2) {
return this.where(...args).all();
}
const q = this.query();
// 1. Try to fetch results from cache
// 2. If there are any cached results, return results from cache, apply map to them, and return
// 3. If there aren't any cached results, run the query, get results, load relations
// 4. Cache the resulting dataset if needed
// 5. Apply maps to resulting dataset, and return
if (q._orm.cacheEnabled) {
const cacheKey = this.queryCacheKey(q);
return this.cache.get(cacheKey, null).then((models) => {
if (models === null) {
return q
.then((models) => this.processResult(models))
.then((models) => this.loadRelations(models, q._orm.eagerLoads))
.then((models) => {
return this.cache.set(cacheKey, models, q._orm.cacheLifetime).then(() => models);
})
.then((models) => models.map((m) => q._orm.maps.reduce((m, map) => map(m), m)))
;
} else {
return models.map((m) => q._orm.maps.reduce((m, map) => map(m), m));
}
});
} else {
return q
.then((models) => this.processResult(models))
.then((models) => this.loadRelations(models, q._orm.eagerLoads))
.then((models) => models.map((m) => q._orm.maps.reduce((m, map) => map(m), m)))
;
}
}
/**
* get query that'll be executed for counts
* @return {knex.query} query to be executed to get count of scoped rows
*/
countQuery() {
return this.attachOrmNSToQuery(
this.orm.knex.count('*').from((q) => {
q.from(this.tableName());
this.scopeTrack.apply(q);
if (!this.scopeTrack.hasScope('select')) {
q.select(this.c('*'));
}
q.as('t1');
})
);
}
/**
* get count of the scoped result set. works well
* even when you have groupBy etc in your queries
* @param {...mixed} args conditions for scoping the query
* @return {int} count of the result set
*/
count(...args) {
if (args.length === 1) {
return this.where(args[0]).count();
} else if (args.length >= 2) {
return this.where(...args).count();
}
const q = this.countQuery();
if (q._orm.cacheEnabled) {
const cacheKey = this.queryCacheKey(q);
return this.cache.get(cacheKey, null).then((result) => {
if (result === null) {
return q.then((result) => {
return this.cache.set(cacheKey, result, q._orm.cacheLifetime).then(() => result);
}).then((result) => {
return this.processResult(result, {count: true});
});
} else {
return this.processResult(result, {count: true});
}
});
} else {
return q.then((result) => this.processResult(result, {count: true}));
}
}
/**
* check whether an 'orderBy' or an 'orderByRaw' clause exists
* in the this.scopeTrack. If not, return a fork with an orderBy clause
* based on key-columns
* @return {Table} ordered fork of current table
*/
orderedFork() {
const labels = this.scopeTrack.scopes.map(({label}) => label);
if (labels.indexOf('orderBy') > -1 || labels.indexOf('orderByRaw') > -1) {
return this.fork();
} else {
if (isPostgres(this.orm)) {
return this.fork().orderByRaw(
`(${(isArray(this.key()) ? this.key() : [this.key()]).map((c) => `${this.c(c)}::text`).join(` || '-' || `)}) desc`
);
} else if (isMysql(this.orm)) {
return this.fork().orderByRaw(
`concat(${(isArray(this.key()) ? this.key() : [this.key()]).map((c) => `${this.c(c)}`).join(`, '-', `)}) desc`
);
} else {
throw new UnsupportedDbError;
}
}
}
/**
* perform a reduce operation on your scoped rows in batches
* @param {int} batchSize number of rows fetched per batch
* @param {function} reducer reducer of a batch
* @param {mixed} initialVal inital value for the reduce operation
* @return {mixed} result of the batchReduce operation
*/
batchReduce(batchSize=1000, reducer=(() => {}), initialVal=null) {
const reduceBatchN = (batchNum, batchInitialVal) => {
return this.orderedFork().limit(batchSize).offset((batchNum-1) * batchSize).all()
.then((models) => {
try {
const batchResult = reducer(batchInitialVal, models);
if (models.length < batchSize) {
return batchResult;
} else {
return reduceBatchN(batchNum+1, batchResult);
}
} catch (err) {
throw err;
}
});
};
return reduceBatchN(1, initialVal);
}
/**
* perform a reduce operation on your scoped set of rows, by row
* @param {function} reducer reducer of a row
* @param {mixed} initialVal initial value for the reduce operation
* @return {mixed} result of the reduce operation
*/
reduce(reducer=(() => {}), initialVal=null) {
return this.orderedFork().batchReduce(1000, (batchInitialVal, models) => {
return models.reduce((val, model) => reducer(val, model), batchInitialVal);
}, initialVal);
}
/**
* perform a map on your scoped set of rows, by row
* @param {function} map map of a row
* @return {mixed} result of map
*/
map(map=(() => {})) {
return this.scope((q) => { q._orm.maps.push(map); }, 'map');
}
/**
* apply a scoper on the table
* @param {...mixed} scoper, or scoper config to be applied on the table
* @param {object} params for applying the scoper
* @return {this} current table instance
*/
applyScoper(scoper={}, params={}) {
scoper = scoper instanceof Scoper ? scoper : (new Scoper(scoper));
return scoper.apply(this, params);
}
/**
* find a single model for supplied conditions
* @param {...mixed} args conditions for finding the model
* @return {Promise} promise for found model
*/
find(...args) {
switch (args.length) {
case 0: return this.first();
case 1: return ((val) => {
if (isUsableObject(val)) {
return this.where(val).first();
} else {
return this.whereKey(val).first();
}
})(...args);
default: return this.where(...args).first();
}
}
/**
* delete the scoped data set
* @param {...mixed} args further conditions for deletion
* @return {Promise} promise for when deletion has completed
*/
del(...args) {
switch (args.length) {
case 0: return this.query().del();
case 1: return ((condition) => {
if (isUsableObject(condition)) {
return this.where(condition).del();
} else {
// else we are deleting based on a key
return this.whereKey(condition).del();
}
})(...args);
default: return this.where(...args).del();
}
}
/**
* another version of del
* @param {...mixed} args further conditions for deletion
* @return {Promise} promise for when deletion has completed
*/
delete(...args) {
return this.del(...args);
}
/**
* truncate the table
* @return {Promise} promise for when truncate has completed
*/
truncate() {
return this.newQuery().truncate();
}
/**
* get timestamp cols being used by the table
* @return {array} [createdAtCol, updatedAtCol]
*/
timestampsCols() {
const timestamps = isArray(this.props.timestamps) ? this.props.timestamps : ['created_at', 'updated_at'];
if (timestamps.length === 1) {
return [timestamps[0], timestamps[0]];
} else {
return timestamps;
}
}
/**
* attach timestamp to values
* @param {mixed} values values to be timestamped
* @param {options} options.op operation being performed (insert/update)
* @return {mixed} timestamped
*/
attachTimestampToValues(values, {op}) {
if (isArray(values)) {
return values.map((val) => this.attachTimestampToValues(val));
}
if (this.props.timestamps !== false) {
const timestamp = new Date();
const [createdAtCol, updatedAtCol] = this.timestampsCols();
if (!isDate(values[createdAtCol]) && op === 'insert') {
assign(values, {[createdAtCol]: timestamp});
}
if (!isDate(values[updatedAtCol]) && ['insert', 'update'].indexOf(op) > -1) {
assign(values, {[updatedAtCol]: timestamp});
}
}
return values;
}
/**
* generate a new key-val if uuid true
* @return {Promise} uuid
*/
genKeyVal() {
if (!this.props.uuid) {
return Promise.resolve({});
}
const key = isArray(this.key()) ? this.key() : [this.key()];
const newKey = key.reduce((condition, part) => {
return assign(condition, {[part]: uuid.v4()});
}, {});
return Promise.resolve(newKey);
}
/**
* pick only those {key: val} where key is a table
* column. useful for table insertion and updation
* @param {array} columns an array of column-names
* @param {mixed} values array or object of values
* @return {mixed} array or object of values
*/
pickColumnValues(columns, values, {op}) {
if (isArray(values)) {
return values.map((v) => this.pickColumnValues(columns, v));
}
const keys = isArray(this.key()) ? this.key() : [this.key];
return columns
.filter((col) => {
if (op === 'update' && keys.indexOf(col) > -1) {
return false;
} else {
return true;
}
})
.reduce((parsed, col) => {
if (col in values) {
return assign(parsed, {[col]: values[col]});
} else {
return parsed;
}
}, {})
;
}
/**
* insert values in the table
* @param {object} model model or array of models to be inserted
* @return {Promise} promise for when insert has completed
*/
insertModel(model) {
const opFlags = {op: 'insert'};
return Promise.all([this.columns(), this.genKeyVal()])
.then(([columns, keyVal]) => {
model = assign({}, keyVal, this.attachTimestampToValues(
this.pickColumnValues(columns, model, opFlags),
opFlags
));
if (isPostgres(this.orm)) {
return this.newQuery().returning('*').insert(model).then(([model]) => model);
} else if (isMysql(this.orm)) {
if (this.props.increments) {
return this.newQuery().insert(model).then(([id]) => ({
...model,
id
}));
} else {
return this.newQuery().insert(model).then(() => model);
}
} else {
throw new UnsupportedDbError;
}
})
;
}
insertAll(models=[]) {
return Promise.all(models.map((m) => this.insertModel(m)));
}
insert(values) {
if (isArray(values)) {
return this.insertAll(values);
} else {
return this.insertModel(values);
}
}
/**
* update the scoped data set
* @param {...mixed} args data for updation, or key and data for updation
* @return {Promise} promise for when updation has completed
*/
update(...args) {
if (args.length >= 2) {
// that means we have been provided a key, and values to update
// against it
const [keyCondition, values] = args;
return this.whereKey(keyCondition).rawUpdate(values, {returning: true});
} else if (args.length === 1) {
// if we reach here then we can safely say that an object has
// been provided to the update call
return this.rawUpdate(args[0], {});
} else {
return Promise.reject(new Error('Invalid update'));
}
}
/**
* Perform an update operation, can be used for batch updates.
* @param {object} values values to be used to perform an update
* @param {Boolean} options.returning whether the results should be returned
* @return {Promise} promise for when update has finished
*/
rawUpdate(values, {returning=false}) {
const opFlags = {op: 'update'};
return this.columns().then((columns) => {
values = this.attachTimestampToValues(
this.pickColumnValues(columns, values, opFlags),
opFlags
);
if (returning === true) {
// we return the first object when returning is true
// use update method uses this, useful for handpicked updates
// which is mostly the case with business logic
if (isPostgres(this.orm)) {
return this.query().returning('*').update(values).then(([model]) => model);
} else if (isMysql(this.orm)) {
return this.fork().first().then(model => {
return this.query().update(values).then(() => ({
...model,
...values
}));
});
} else {
throw new UnsupportedDbError;
}
} else {
// use this for batch updates. we don't return anything
// in batch updates. if you want returning batch updates,
// just use knex!
return this.query().update(values);
}
});
}
/**
* get a new hasOne relation
* @param {string} related related table name
* @param {string} foreignKey foreign-key on related table
* @param {string} key key to match with on this table
* @return {HasOne} HasOne relation instance
*/
hasOne(related, foreignKey, key) {
key = key || this.key();
return new HasOne(this, this.table(related), foreignKey, key);
}
/**
* get a new hasMany relation
* @param {string} related related table name
* @param {string} foreignKey foreign-key on related table
* @param {string} key key to match with on this table
* @return {HasMany} HasMany relation instance
*/
hasMany(related, foreignKey, key) {
key = key || this.key();
return new HasMany(this, this.table(related), foreignKey, key);
}
/**
* get a new hasManyThrough relation
* @param {string} related related table name
* @param {string} through through table name
* @param {string} firstKey foreign-key on through table
* @param {string} secondKey foreign-key on related table
* @return {HasManyThrough} HasManyThrough relation instance
*/
hasManyThrough(related, through, firstKey, secondKey) {
return new HasManyThrough(
this, this.table(related), this.table(through), firstKey, secondKey
);
}
/**
* get a new BelongsTo relation
* @param {string} related related table name
* @param {string} foreignKey foreign-key on this table
* @param {string} otherKey key to match on other table
* @return {BelongsTo} BelongsTo relation instance
*/
belongsTo(related, foreignKey, otherKey) {
related = this.table(related);
otherKey = otherKey || related.key();
return new BelongsTo(this, related, foreignKey, otherKey);
}
/**
* get a new ManyToMany relation
* @param {string} related related table name
* @param {string} pivot pivot table name
* @param {string} foreignKey foreign-key on this table
* @param {string} otherKey other-key on this table
* @param {function} joiner extra join conditions
* @return {ManyToMany} BelongsToMany relation instance
*/
manyToMany(related, pivot, foreignKey, otherKey, joiner=(() => {})) {
return new ManyToMany(
this, this.table(related), this.table(pivot), foreignKey, otherKey,
joiner
);
}
/**
* get a new MorphOne relation
* @param {string} related related table name
* @param {string} inverse inverse relation ship name
* @return {MorphOne} MorphOne relation instance
*/
morphOne(related, inverse) {
related = this.table(related);
return new MorphOne(this, related, related[inverse]());
}
/**
* get a new MorphMany relation
* @param {string} related related table name
* @param {string} inverse inverse relation ship name
* @return {MorphMany} MorphMany relation instance
*/
morphMany(related, inverse) {
related = this.table(related);
return new MorphMany(this, related, related[inverse]());
}
/**
* get a new MorphTo relation
* @param {array} tables array of table names this relation morph's to
* @param {string} typeField type-field name
* @param {string} foreignKey foreign-key name
* @return {MorphTo} MorphTo relation instance
*/
morphTo(tables, typeField, foreignKey) {
tables = tables.map((t) => this.table(t));
return new MorphTo(this, tables, typeField, foreignKey);
}
}
function isMysql(orm) {
return orm.knex.context.client.config.client === 'mysql';
}
function isPostgres(orm) {
return ['pg', 'postgresql'].indexOf(orm.knex.context.client.config.client) > -1;
}
class UnsupportedDbError extends Error {}
module.exports = Table;