streamsql
Version:
Streaming SQL ORM
598 lines (490 loc) • 16.5 kB
JavaScript
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
}