mohair
Version:
mohair is a simple and flexible sql builder with a fluent interface
590 lines (468 loc) • 16.7 kB
text/coffeescript
_ = require 'lodash'
criterion = require 'criterion'
# pull in some helpers from criterion
# TODO put this directly on criterion
{
implementsSqlFragmentInterface
} = criterion.helper
################################################################################
# PROTOTYPES & FACTORIES
# prototype objects for the action-objects that represent sql actions.
# sql actions are: select, insert, update and delete.
# action-objects store just the state specific to that action.
# the rest is stored in mohair itself.
prototypes = {}
# factory functions that make action-objects by prototypically
# inheriting from the prototypes.
# try to catch errors in the factory functions.
factories = {}
################################################################################
# PARTIALS
################################################################################
# items joined by a separator character
prototypes.joinedItems =
sql: (escape) ->
parts = []
@_items.forEach (item) ->
if implementsSqlFragmentInterface item
# sql fragment
itemSql = item.sql(escape)
unless item.dontWrap
itemSql = '(' + itemSql + ')'
parts.push itemSql
else
# simple string
parts.push item
return parts.join @_join
params: ->
params = []
@_items.forEach (item) ->
if implementsSqlFragmentInterface item
# sql fragment
params = params.concat item.params()
return params
dontWrap: true
factories.joinedItems = (join, items...) ->
_.create prototypes.joinedItems,
_join: join
_items: _.flatten items
################################################################################
# aliases: `table AS alias`
prototypes.aliases =
sql: (escape) ->
object = @_object
escapeStringValues = @_escapeStringValues
parts = []
Object.keys(object).forEach (key) ->
value = object[key]
if implementsSqlFragmentInterface value
valueSql = value.sql(escape)
unless value.dontWrap
valueSql = '(' + valueSql + ')'
parts.push valueSql + ' AS ' + escape(key)
else
parts.push (if escapeStringValues then escape(value) else value) + ' AS ' + escape(key)
parts.join(', ')
params: ->
object = @_object
params = []
# aliased
Object.keys(object).forEach (key) ->
value = object[key]
if implementsSqlFragmentInterface value
params = params.concat value.params()
params
dontWrap: true
factories.aliases = (object, escapeStringValues = false) ->
if Object.keys(object).length is 0
throw new Error 'alias object must have at least one property'
_.create prototypes.aliases,
_object: object
_escapeStringValues: escapeStringValues
################################################################################
# select outputs
# plain strings are treated raw and not escaped
# objects are used for aliases
factories.selectOutputs = (outputs...) ->
if outputs.length is 0
return criterion('*')
factories.joinedItems ', ', _.flatten(outputs).map (output) ->
if implementsSqlFragmentInterface output
output
else if 'object' is typeof output
# alias object
factories.aliases output
else
# raw strings are not escaped
criterion output
################################################################################
# from items
factories.fromItems = (items...) ->
factories.joinedItems ', ', _.flatten(items).map (item) ->
if implementsSqlFragmentInterface item
item
else if 'object' is typeof item
# alias object
escapeStringValues = true
factories.aliases item, escapeStringValues
else
# strings are interpreted as table names and escaped
criterion.escape item
################################################################################
# ACTIONS: select, insert, update, delete
################################################################################
# select
prototypes.select =
sql: (mohair, escape) ->
sql = ''
# common table expression ?
if mohair._with?
sql += 'WITH '
parts = []
parts = Object.keys(mohair._with).map (key) ->
escape(key) + ' AS (' + criterion(mohair._with[key]).sql(escape) + ')'
sql += parts.join(', ')
sql += ' '
sql += "SELECT"
if mohair._distinct?
sql += " DISTINCT #{mohair._distinct}"
sql += " "
# what to select
sql += @_outputs.sql(escape)
# where to select from:
# from takes precedence over table
if mohair._from?
sql += " FROM #{mohair._from.sql(escape)}"
else if mohair._table?
sql += " FROM #{escape mohair._table}"
mohair._joins.forEach (join) ->
sql += " #{join.sql}"
if join.criterion?
sql += " AND (#{join.criterion.sql(escape)})"
# how to modify the select
if mohair._where?
sql += " WHERE #{mohair._where.sql(escape)}"
if mohair._group?
sql += " GROUP BY #{mohair._group.join(', ')}"
if mohair._having?
sql += " HAVING #{mohair._having.sql(escape)}"
if mohair._window?
sql += " WINDOW #{mohair._window}"
if mohair._order?
sql += " ORDER BY #{mohair._order.join(', ')}"
if mohair._limit?
sql += ' LIMIT '
if implementsSqlFragmentInterface mohair._limit
sql += mohair._limit.sql(escape)
else
sql += '?'
if mohair._offset?
sql += ' OFFSET '
if implementsSqlFragmentInterface mohair._offset
sql += mohair._offset.sql(escape)
else
sql += '?'
if mohair._for?
sql += " FOR #{mohair._for}"
# combination with other queries ?
if mohair._combinations?
mohair._combinations.forEach (combination) ->
sql += " #{combination.operator} #{combination.query.sql(escape)}"
return sql
params: (mohair) ->
params = []
if mohair._with?
Object.keys(mohair._with).forEach (key) ->
params = params.concat criterion(mohair._with[key]).params()
params = params.concat @_outputs.params()
if mohair._from?
params = params.concat mohair._from.params()
mohair._joins.forEach (join) ->
if join.criterion?
params = params.concat join.criterion.params()
if mohair._where?
params = params.concat mohair._where.params()
if mohair._having?
params = params.concat mohair._having.params()
if mohair._limit?
if implementsSqlFragmentInterface mohair._limit
params = params.concat mohair._limit.params()
else
params.push mohair._limit
if mohair._offset?
if implementsSqlFragmentInterface mohair._offset
params = params.concat mohair._offset.params()
else
params.push mohair._offset
if mohair._combinations?
mohair._combinations.forEach (combination) ->
params = params.concat combination.query.params()
return params
factories.select = (outputs...) ->
_.create prototypes.select,
_outputs: factories.selectOutputs outputs...
################################################################################
# insert
prototypes.insert =
sql: (mohair, escape) ->
unless mohair._table?
throw new Error '.sql() of insert action requires call to .table() before it'
if mohair._from?
throw new Error '.sql() of insert action ignores and does not allow call to .from() before it'
table = escape mohair._table
records = @_records
keys = Object.keys(records[0])
escapedKeys = keys.map escape
rows = records.map (record) ->
row = keys.map (key) ->
if implementsSqlFragmentInterface record[key]
record[key].sql(escape)
else
'?'
return "(#{row.join ', '})"
sql = "INSERT INTO #{table}(#{escapedKeys.join ', '}) VALUES #{rows.join ', '}"
if mohair._returning?
sql += " RETURNING #{mohair._returning.sql(escape)}"
return sql
params: (mohair) ->
records = @_records
keys = Object.keys(records[0])
params = []
records.forEach (record) ->
keys.forEach (key) ->
if implementsSqlFragmentInterface record[key]
params = params.concat record[key].params()
else
params.push record[key]
if mohair._returning?
params = params.concat mohair._returning.params()
return params
factories.insert = (recordOrRecords) ->
if Array.isArray recordOrRecords
if recordOrRecords.length is 0
throw new Error 'array argument is empty - no records to insert'
msg = 'all records in the array argument must have the same keys.'
keysOfFirstRecord = Object.keys recordOrRecords[0]
if keysOfFirstRecord.length is 0
throw new Error "can't insert empty object"
recordOrRecords.forEach (record) ->
keys = Object.keys record
if keys.length isnt keysOfFirstRecord.length
throw new Error msg
keysOfFirstRecord.forEach (key) ->
value = record[key]
# null values are allowed !
if not value? and record[key] isnt null
throw new Error msg
return _.create prototypes.insert, {_records: recordOrRecords}
if 'object' is typeof recordOrRecords
if Object.keys(recordOrRecords).length is 0
throw new Error "can't insert empty object"
return _.create prototypes.insert, {_records: [recordOrRecords]}
throw new TypeError 'argument must be an object or an array'
################################################################################
# update
prototypes.update =
sql: (mohair, escape) ->
updates = @_updates
unless mohair._table?
throw new Error '.sql() of update action requires call to .table() before it'
table = escape mohair._table
keys = Object.keys updates
updatesSql = keys.map (key) ->
escapedKey = escape key
if implementsSqlFragmentInterface updates[key]
"#{escapedKey} = #{updates[key].sql(escape)}"
else
"#{escapedKey} = ?"
sql = "UPDATE #{table} SET #{updatesSql.join ', '}"
if mohair._from?
sql += " FROM #{mohair._from.sql(escape)}"
if mohair._where?
sql += " WHERE #{mohair._where.sql(escape)}"
if mohair._returning?
sql += " RETURNING #{mohair._returning.sql(escape)}"
return sql
params: (mohair) ->
updates = @_updates
params = []
Object.keys(updates).forEach (key) ->
value = updates[key]
if implementsSqlFragmentInterface value
params = params.concat value.params()
else
params.push value
if mohair._from?
params = params.concat mohair._from.params()
if mohair._where?
params = params.concat mohair._where.params()
if mohair._returning?
params = params.concat mohair._returning.params()
return params
factories.update = (updates) ->
if Object.keys(updates).length is 0
throw new Error 'nothing to update'
_.create prototypes.update,
_updates: updates
################################################################################
# delete
prototypes.delete =
sql: (mohair, escape) ->
unless mohair._table?
throw new Error '.sql() of delete action requires call to .table() before it'
table = escape mohair._table
sql = "DELETE FROM #{table}"
# from for delete acts as using
if mohair._from?
sql += " USING #{mohair._from.sql(escape)}"
if mohair._where?
sql += " WHERE #{mohair._where.sql(escape)}"
if mohair._returning?
sql += " RETURNING #{mohair._returning.sql(escape)}"
return sql
params: (mohair) ->
params = []
if mohair._from?
params = params.concat mohair._from.params()
if mohair._where?
params = params.concat mohair._where.params()
if mohair._returning?
params = params.concat mohair._returning.params()
return params
factories.delete = ->
_.create prototypes.delete
################################################################################
# MOHAIR FLUENT API
Mohair = (source) ->
if source
# only copy OWN properties.
# don't copy properties on the prototype.
# OWN properties are just non-default values and user defined methods.
# OWN properties tend to be very few for most queries.
for own k, v of source
this[k] = v
return this
Mohair.prototype =
################################################################################
# core
fluent: (key, value) ->
next = new Mohair @
next[key] = value
return next
_escape: _.identity
escape: (arg) -> @fluent '_escape', arg
# the default action is select *
_action: factories.select '*'
################################################################################
# actions
insert: (args...) -> @fluent '_action', factories.insert args...
select: (args...) -> @fluent '_action', factories.select args...
delete: -> @fluent '_action', factories.delete()
update: (data) -> @fluent '_action', factories.update data
################################################################################
# for select action only
with: (arg) ->
unless ('object' is typeof arg) and Object.keys(arg).length isnt 0
throw new Error 'with must be called with an object that has at least one property'
@fluent '_with', arg
distinct: (arg = '') ->
@fluent '_distinct', arg
group: (args...) ->
@fluent '_group', args
window: (arg) ->
@fluent '_window', arg
order: (args...) ->
@fluent '_order', args
limit: (arg) ->
@fluent '_limit',
if implementsSqlFragmentInterface arg
arg
else
parseInt(arg, 10)
offset: (arg) ->
@fluent '_offset',
if implementsSqlFragmentInterface arg
arg
else
parseInt(arg, 10)
for: (arg) ->
@fluent '_for', arg
################################################################################
# from
# supports multiple tables, subqueries and aliases
# from: (from...) ->
# @fluent '_from', from...
getTable: ->
@_table
# table must be a simple string
table: (table) ->
if 'string' isnt typeof table
throw new Error 'table must be a string. use .from() to call with multiple tables or subqueries.'
@fluent '_table', table
from: (args...) ->
@fluent '_from', factories.fromItems args...
_joins: []
join: (sql, criterionArgs...) ->
join = {sql: sql}
join.criterion = criterion criterionArgs... if criterionArgs.length isnt 0
next = new Mohair @
# slice without arguments clones an array
next._joins = @_joins.slice()
next._joins.push join
return next
################################################################################
# where conditions
where: (args...) ->
where = criterion args...
@fluent '_where', if @_where? then @_where.and(where) else where
having: (args...) ->
having = criterion args...
@fluent '_having', if @_having? then @_having.and(having) else having
################################################################################
# returning (ignored for select)
returning: (args...) ->
# returning can be disabled by calling without arguments
if args.length is 0
@fluent '_returning', null
else
@fluent '_returning', factories.selectOutputs args...
################################################################################
# combining queries (select only)
_combinations: []
combine: (query, operator) ->
# slice without arguments clones an array
combinations = @_combinations.slice()
combinations.push
query: query
operator: operator
@fluent '_combinations', combinations
union: (query) ->
@combine query, 'UNION'
unionAll: (query) ->
@combine query, 'UNION ALL'
intersect: (query) ->
@combine query, 'INTERSECT'
intersectAll: (query) ->
@combine query, 'INTERSECT ALL'
except: (query) ->
@combine query, 'EXCEPT'
exceptAll: (query) ->
@combine query, 'EXCEPT ALL'
################################################################################
# helpers
# call a one-off function as if it were part of mohair
call: (fn, args...) ->
fn.apply @, args
raw: (sql, params...) ->
criterion sql, params...
################################################################################
# implementation of sql-fragment interface
sql: (escape) ->
# escape can be passed in to override the escape set on this mohair
@_action.sql @, (escape or @_escape)
params: ->
@_action.params @
implementsSqlFragmentInterface: implementsSqlFragmentInterface
################################################################################
# exports
module.exports = new Mohair