UNPKG

tabel

Version:

A simple orm for PostgreSQL which works with simple javascript objects and arrays

1,632 lines (1,455 loc) 47.9 kB
/* { // 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;