UNPKG

streamsql

Version:
598 lines (490 loc) 16.5 kB
const util = require('util') const Stream = require('stream') const sqlstring = require('../sqlstring') const fmt = util.format.bind(util) const JOIN_SEP = ':@:' module.exports = { escape: function escape(value) { return sqlstring.escape(value) }, escapeId: function escapeId(id) { return sqlstring.escapeId(id) }, whereSql: function whereSql(table, conditions) { const escape = this.escape const escapeId = this.escapeId try { var sql = build(conditions) if (sql) sql = ' WHERE ' + sql return sql } catch (e) { return e; } function build (conditions) { if (!Array.isArray(conditions)) return generateSql(conditions) const groups = conditions.map(function (subset) { return '(' + build(subset) + ')' }) // it's unclear as to whether a {} subset query should be // included or not - does `get([{id:1}, {}])` return everything, // or should the {} be ignored? // for now, we're eliminating empty groups return groups.join(' OR ').replace(/\s*OR \(\s*\)/, '') } function generateSql (conditions) { const hasConditons = conditions && keys(conditions).length if (!hasConditons) return '' const clauses = map(conditions, function (val, key) { const field = escapeId([table, key].join('.')) var cnd = conditions[key] if (Array.isArray(cnd)) { // if the condition is a simple array, // e.g { release_date: [2000,1996] } // use an `in` operator. if (typeof cnd[0] != 'object') { cnd = cnd.map(function (x) { return escape(x) }) return fmt('%s IN (%s)', field, cnd.join(',')) } return cnd.map(function (subcnd) { const operation = subcnd.op || subcnd.operation const value = escape(subcnd.value) return fmt('%s %s %s', field, operation, value) }).join(' AND ') } if (cnd !== null) { if (typeof cnd == 'undefined') throw new RangeError('Condition for `'+key+'` cannot be undefined') const op = cnd.operation || cnd.op || '=' if (cnd.value) cnd = cnd.value return fmt('%s %s %s', field, op, escape(cnd)) } return fmt('%s IS NULL', field) }) return clauses.join(' AND ') } }, orderSql: function orderSql(table, order) { if (!order) return '' const escape = this.escape const escapeId = this.escapeId // order can be one of three styles: // * implicit ascending, single: 'title' // * implicit ascending, multi: ['release_date', 'title'] // * explicit: { title: 'desc', release_date: 'asc' } if (typeof order == 'string') { return fmt(' ORDER BY %s ', escapeId(order)) } if (Array.isArray(order)) return fmt(' ORDER BY %s ', order.map(escapeId).join(',')) // must be an object return fmt(' ORDER BY %s ', map(order, function (value, key) { return fmt('%s %s', escapeId(key), value.toUpperCase()) }).join(',')) }, limitSql: function limitSql(limit, page) { if (!limit) return '' if (!page) return fmt(' LIMIT %s ', limit) const beginning = (page - 1) * limit return fmt(' LIMIT %s,%s ', beginning, limit) }, selectSql: function selectSql(opts) { const escapeId = this.escapeId const escape = this.escape const table = opts.table const tables = opts.tables const fields = opts.fields const conds = opts.conditions const includeFields = opts.include const excludeFields = opts.exclude const relationships = opts.relationships const useRaw = (Array.isArray(conds) && typeof conds[0] === 'string') || typeof conds === 'string' const useJoin = (relationships && keys(relationships).length) if (useRaw) return rawSql.call(this) if (useJoin) return withJoin.call(this) const fieldList = filterFields(fields, includeFields, excludeFields) .map(escapeId).join(',') const clauses = [ fmt('SELECT %s FROM %s', fieldList, escapeId(table)), this.whereSql(table, conds), this.orderSql(table, opts.order), this.limitSql(opts.limit, opts.page) ] const error = firstErrorInArray(clauses) if (error) return error return clauses.join(' ') function rawSql() { const statementPair = typeof conds == 'string' ? [conds, []] : conds const sql = statementPair[0].replace(/\$table/i, escapeId(table)) const values = statementPair[1].map(escape) const statement = values.reduce(function (sql, value) { return sql.replace('?', value) }, sql) return statement } function withJoin() { var allFields = fields .slice().map(function (field) { return [table, field].join('.') }) var joinString = '' this.normalizeRelationships(table, relationships) function addRelationships (relationships, tableAlias) { // in the loop below, rel --> // { 'type': <string>, // 'local': <{table, key}>, // 'foreign': <{table, key, [as]}>, // 'relationships': <{name*: rel}>, // 'optional': <null | boolean> } forEach(relationships, function (rel, name) { if (rel.type == 'hasMany') return var foreignAs = name var localTable = rel.local.table rel.foreign.as = tableAlias + JOIN_SEP + foreignAs rel.local.table = tableAlias allFields = allFields.concat(getFields(rel.foreign, tables)) joinString += this.joinSql(rel) if (rel.relationships) addRelationships.call(this, rel.relationships, tableAlias + JOIN_SEP + name) rel.foreign.as = foreignAs rel.local.table = localTable }.bind(this)) } addRelationships.call(this, relationships, table) const selectFields = filterFields(allFields, includeFields, excludeFields) const joinClause = fmt('SELECT %s FROM %s %s', selectAs(selectFields), escapeId(table), joinString) const clauses = [ joinClause, this.whereSql(table, conds), this.orderSql(table, opts.order), this.limitSql(opts.limit, opts.page), ] const error = firstErrorInArray(clauses) if (error) return error return clauses.join(' ') } function selectAs(fields) { return fields.map(function (field) { return fmt( '%s AS %s', escapeId(field), escapeId(field.replace('.', JOIN_SEP))) }).join(',') } function getFields(tableWithAlias, tables) { // XXX: probably shouldn't throw const name = tableWithAlias.table const alias = tableWithAlias.as || name const tableDef = tables[name] if (!tableDef) throw new Error(fmt('table %s is not registered', escapeId(name))) return tableDef.fields.map(function (field) { return [alias, field].join('.') }) } }, parseJoinRow: function (row, table) { const fixed = {} forEach(row, function (value, key) { const parts = key.split(JOIN_SEP) const property = parts.pop() var target = fixed forEach(parts, function (part) { target = (target[part] || (target[part] = {})) }) target[property] = value }); return fixed[table] || {} }, fixHasOne: function (row, opts) { const table = opts.table const relationships = opts.relationships const tableCache = opts.tableCache row = this.parseJoinRow(row, table) fixRelationships(row, relationships) function fixRelationships (target, relationships) { forEach(relationships, function (rel, relKey) { if (rel.type !== 'hasOne') return const foreign = tableCache[rel.foreign.table] target[relKey] = new foreign.constructor(target[relKey]) if (rel.relationships) fixRelationships(target[relKey], rel.relationships) }) } return row }, fixHasMany: function (globalRow, opts, callback) { var globalError const table = opts.table const tableCache = opts.tableCache const hasMany = {} findRelationships(opts.relationships) if (!keys(hasMany).length) return deferCallback(callback, null, globalRow) this.normalizeRelationships(table, hasMany) var waiting = keys(hasMany).length forEach(hasMany, function (rel, relKey) { if (globalError) return function handleResult (err, rows) { if (globalError) return if (err) { globalError = err return callback(err, null) } const parts = relKey.split('.') const property = parts.pop() const context = parts.reduce(function (context, part) { if (!context[part]) context[part] = [] return context[part] }, globalRow); context[property] = rows.map(function (row) { return new rel.foreign.constructor(row) }) return checkDone() } (rel.via ? fetchVia : fetchDirect)(rel, handleResult); }) function fetchVia (rel, callback) { const cond = {} const table = tableCache[rel.via.table] const options = {relationships: {}} options.relationships[rel.foreign.table] = { type: 'hasOne', local: rel.via.foreign, foreign: rel.foreign, relationships: rel.relationships || {} } cond[rel.via.local] = globalRow[rel.local.key] table.get(cond, options, function (err, rows) { if (err) return callback(err); return callback(null, rows.map(function (row) { return row[rel.foreign.table]; })); }) } function fetchDirect (rel, callback) { const cond = {} const table = tableCache[rel.foreign.table] const options = {relationships: rel.relationships || {}} cond[rel.foreign.key] = globalRow[rel.local.key] table.get(cond, options, callback) } function findRelationships (relationships, prefix) { prefix = prefix || '' forEach(relationships, function (rel, relKey) { if (rel.type == 'hasMany') hasMany[prefix + relKey] = rel if (rel.relationships) findRelationships(rel.relationships, prefix + relKey + '.') }) } function checkDone() { if (globalError) return if (--waiting > 0) return return callback(null, globalRow) } }, hydrateRow: function (row, opts, callback) { if (!row || isEmpty(opts.relationships)) return deferCallback(callback, null, row) row = this.fixHasOne(row, { table: opts.table, relationships: opts.relationships, tableCache: opts.tableCache, }) this.fixHasMany(row, { table: opts.table, relationships: opts.relationships, tableCache: opts.tableCache, }, callback) }, hydrateRows: function (rows, opts, callback) { const results = [] var waiting = rows.length var globalError if (!rows.length || isEmpty(opts.relationships)) return deferCallback(callback, null, rows) rows.forEach(function (row, idx) { this.hydrateRow(row, opts, function (err, result) { if (globalError) return if (err) { globalError = err return callback(err) } storeResult(result, idx) }) }, this) function storeResult(result, pos) { results[pos] = result if (--waiting > 0) return callback(null, results) } }, streamHandlers: function (stream, opts) { var waiting = 0 var finishing = false const driver = this const table = opts.table const relationships = opts.relationships const tableCache = opts.tableCache const RowClass = tableCache[table].constructor const withRelationships = (relationships && keys(relationships).length) function onResult(row) { if (!withRelationships) { return stream.emit('data', new RowClass(row)) } waiting ++ // fulfills both hasOne and hasMany relationships driver.hydrateRow(row, { table: table, relationships: relationships, tableCache: tableCache, }, function (err, row) { waiting -- if (err) return stream.emit(err) stream.emit('data', new RowClass(row)) checkDone() }) } function endCallback(err) { if (err) return stream.emit('error', err) if (waiting > 0) { finishing = true return false } stream.emit('end') } function checkDone() { if (finishing && !waiting) stream.emit('end') } return { row: onResult, done: endCallback } }, normalizeRelationships: function (table, relationships) { forEach(relationships, function (rel, relKey) { if (!rel.local) rel.local = { table: table, key: relKey } if (typeof rel.local === 'string') rel.local = { table: table, key: rel.local } if (rel.relationships) this.normalizeRelationships(rel.foreign.table, rel.relationships) }.bind(this)) return relationships }, joinSql: function (opts) { // opts --> <see `withJoin` above> const local = opts.local const foreign = opts.foreign const joinType = opts.optional ? ' LEFT ' : ' INNER ' const localKey = fmt('%s.%s', local.table, local.key) const foreignTableAlias = foreign.as || foreign.table const foreignKey = fmt('%s.%s', foreignTableAlias, foreign.key) const statement = joinType + ' JOIN ' + this.escapeId(foreign.table) + ' AS ' + this.escapeId(foreignTableAlias) + ' ON ' + this.escapeId(localKey) + ' = ' + this.escapeId(foreignKey) return statement }, insertSql: function insertSql(table, row) { const columns = keys(row) const values = vals(row) const tpl = 'INSERT INTO %s (%s) VALUES (%s)' return fmt(tpl, this.escapeId(table), map(columns, this.escapeId), map(values, this.escape)) }, updateSql: function updateSql(table, row, conds) { const escape = this.escape const escapeId = this.escapeId const pairs = map(row, function (val, col) { return fmt('%s = %s', escapeId(col), escape(val)) }) return fmt('UPDATE %s SET %s %s', escapeId(table), pairs.join(','), this.whereSql(table, conds)) }, deleteSql: function deleteSql(opts) { const table = opts.table const conds = opts.conditions const limit = opts.limit const clauses = [ fmt('DELETE FROM %s', this.escapeId(table)), this.whereSql(table, conds), this.limitSql(limit), ] const error = firstErrorInArray(clauses) if (error) return error return clauses.join(' ') }, } function appearsLast(string, sub) { const index = string.indexOf(sub) if (index == -1) return false return string.slice(index) == sub } function filterFields(fields, includeFields, excludeFields) { if (!includeFields && !excludeFields) return fields // we always include ids because they are needed for proper // relationship fulfillment if (includeFields) includeFields.push('id') return fields.filter(function (field) { function search(needle) { return appearsLast(field, needle) } if (includeFields) return includeFields.some(search) if (excludeFields) return !excludeFields.some(search) return field }) } function map(obj, fn) { return Object.keys(obj).map(function (key) { return fn(obj[key], key) }) } function forEach(obj, fn) { return Object.keys(obj).forEach(function (key) { fn(obj[key], key) }) } function keys(o) { return Object.keys(o) } function vals(o) { return map(o, function (val) { return val }) } function isEmpty(obj) { return (!obj || !keys(obj).length) } function deferCallback(callback, err, arg) { return setImmediate(function () { callback(null, arg) }) } const errorRegExp = /Error$/ function isError(thing) { return (typeof thing == 'object' && errorRegExp.exec(thing.name)) } function firstErrorInArray(array) { for (var idx = 0, item; idx < array.length; idx++) { item = array[idx] if (isError(item)) return item } return null }