criterion
Version:
criterion allows you to work with (build, combine, reuse, ...) SQL-where-conditions ('x = 5 AND y IS NOT NULL'...) as data (goodbye string-concatenation) and compile them to SQL: it has a succinct mongodb-like query-language, a simple and elegant function
483 lines (376 loc) • 14.8 kB
text/coffeescript
###################################################################################
# HELPERS
helper = {}
# return a new object which has `proto` as its prototype and
# all properties in `properties` as its own properties.
helper.beget = beget = (proto, properties) ->
object = Object.create proto
if properties?
for key, value of properties
do (key, value) -> object[key] = value
return object
# if `thing` is an array return `thing`
# otherwise return an array of all key value pairs in `thing` as objects
# example: explodeObject({a: 1, b: 2}) -> [{a: 1}, {b: 2}]
helper.explodeObject = explodeObject = (arrayOrObject) ->
if Array.isArray arrayOrObject
return arrayOrObject
array = []
for key, value of arrayOrObject
do (key, value) ->
object = {}
object[key] = value
array.push object
return array
helper.identity = identity = (x) ->
x
helper.isEmptyArray = isEmptyArray = (x) ->
Array.isArray(x) and x.length is 0
# calls iterator for the values in array in sequence (with the index as the second argument).
# returns the first value returned by iterator for which predicate returns true.
# otherwise returns sentinel.
helper.some = some = (
array
iterator = identity
predicate = (x) -> x?
sentinel = undefined
) ->
i = 0
length = array.length
while i < length
result = iterator array[i], i
if predicate result, i
return result
i++
return sentinel
# flatten array one level
helper.flatten = flatten = (array) ->
[].concat array...
# sql-fragments are treated differently in many situations
helper.implementsSqlFragmentInterface = implementsSqlFragmentInterface = (value) ->
value? and 'function' is typeof value.sql and 'function' is typeof value.params
# normalize sql for fragments and values
helper.normalizeSql = normalizeSql = (fragmentOrValue, escape, ignoreWrap = false) ->
if implementsSqlFragmentInterface fragmentOrValue
sql = fragmentOrValue.sql(escape)
if ignoreWrap or fragmentOrValue.dontWrap then sql else '(' + sql + ')'
else
# if thing is not an sql fragment treat it as a value
"?"
# normalize params for fragments and values
helper.normalizeParams = normalizeParams = (fragmentOrValue) ->
if implementsSqlFragmentInterface fragmentOrValue
fragmentOrValue.params()
# if thing is not an sql fragment treat it as a value
else
[fragmentOrValue]
###################################################################################
# PROTOTYPES AND FACTORIES
# prototype objects for the objects that describe parts of sql-where-conditions
prototypes = {}
dsl = {}
modifiers = {}
# the base prototype for all other prototypes:
# all objects should have the logical operators not, and and or
prototypes.base =
not: -> dsl.not @
and: (args...) -> dsl.and @, criterion args...
or: (args...) -> dsl.or @, criterion args...
###################################################################################
# raw sql
prototypes.rawSql = beget prototypes.base,
sql: ->
unless @_params
return @_sql
i = -1
params = @_params
@_sql.replace /\?/g, ->
i++
# if the param is an array explode into a comma separated list of question marks
if Array.isArray params[i]
(params[i].map -> "?").join ", "
else
"?"
params: ->
flatten @_params
dontWrap: true
rawSql = (sql, params = []) ->
beget prototypes.rawSql, {_sql: sql, _params: params}
###################################################################################
# escape
prototypes.escape = beget prototypes.base,
sql: (escape) ->
return escape @_sql
params: ->
[]
dontWrap: true
dsl.escape = (sql) ->
beget prototypes.escape, {_sql: sql}
###################################################################################
# comparisons: eq, ne, lt, lte, gt, gte
prototypes.comparison = beget prototypes.base,
sql: (escape = identity) ->
"#{normalizeSql @_left, escape} #{@_operator} #{normalizeSql @_right, escape}"
params: ->
normalizeParams(@_left).concat normalizeParams(@_right)
# for when you need arbitrary comparison operators
dsl.compare = (operator, left, right) ->
beget prototypes.comparison, {_left: left, _right: right, _operator: operator}
# make dsl functions and modifier functions for the most common comparison operators
comparisonTable = [
{name: 'eq', modifier: '$eq', operator: '='}
{name: 'ne', modifier: '$ne', operator: '!='}
{name: 'lt', modifier: '$lt', operator: '<'}
{name: 'lte', modifier: '$lte', operator: '<='}
{name: 'gt', modifier: '$gt', operator: '>'}
{name: 'gte', modifier: '$gte', operator: '>='}
].forEach ({name, modifier, operator}) ->
dsl[name] = modifiers[modifier] = (left, right) ->
dsl.compare operator, left, right
###################################################################################
# null
prototypes.null = beget prototypes.base,
sql: (escape = identity) ->
"#{normalizeSql(@_operand, escape)} IS #{if @_isNull then '' else 'NOT '}NULL"
params: ->
normalizeParams(@_operand)
dsl.null = modifiers.$null = (operand, isNull = true) ->
unless operand?
throw new Error '`null` needs an operand'
beget prototypes.null, {_operand: operand, _isNull: isNull}
###################################################################################
# negation
prototypes.not = beget prototypes.base,
sql: (escape = identity) ->
# remove double negation
if isNegation @_inner
ignoreWrap = true
normalizeSql(@_inner._inner, escape, ignoreWrap)
else
"NOT #{normalizeSql(@_inner, escape)}"
params: ->
@_inner.params()
isNegation = (x) ->
prototypes.not.isPrototypeOf x
dsl.not = (inner) ->
unless implementsSqlFragmentInterface inner
throw new Error '`not`: operand must implement sql-fragment interface'
beget prototypes.not, {_inner: inner}
###################################################################################
# exists
prototypes.exists = beget prototypes.base,
sql: (escape = identity) ->
"EXISTS #{normalizeSql(@_operand, escape)}"
params: ->
@_operand.params()
dsl.exists = (operand) ->
unless implementsSqlFragmentInterface operand
throw new Error '`exists` operand must implement sql-fragment interface'
beget prototypes.exists, {_operand: operand}
###################################################################################
# subquery expressions: in, nin, any, neAny, ...
prototypes.subquery = beget prototypes.base,
sql: (escape = identity) ->
sql = ""
sql += normalizeSql @_left, escape
sql += " #{@_operator} "
if implementsSqlFragmentInterface @_right
sql += "#{normalizeSql(@_right, escape)}"
else
questionMarks = []
@_right.forEach -> questionMarks.push '?'
sql += "(#{questionMarks.join ', '})"
return sql
params: ->
params = normalizeParams @_left
if implementsSqlFragmentInterface @_right
params = params.concat @_right.params()
else
# only for $in and $nin: in that case @_value is already an array
params = params.concat @_right
return params
dsl.subquery = (operator, left, right) ->
beget prototypes.subquery, {_left: left, _right: right, _operator: operator}
# make dsl functions and modifier functions for common subquery operators
[
{name: 'in', modifier: '$in', operator: 'IN'}
{name: 'nin', modifier: '$nin', operator: 'NOT IN'}
{name: 'any', modifier: '$any', operator: '= ANY'}
{name: 'neAny', modifier: '$neAny', operator: '!= ANY'}
{name: 'ltAny', modifier: '$ltAny', operator: '< ANY'}
{name: 'lteAny', modifier: '$lteAny', operator: '<= ANY'}
{name: 'gtAny', modifier: '$gtAny', operator: '> ANY'}
{name: 'gteAny', modifier: '$gteAny', operator: '>= ANY'}
{name: 'all', modifier: '$all', operator: '= ALL'}
{name: 'neAll', modifier: '$neAll', operator: '!= ALL'}
{name: 'ltAll', modifier: '$ltAll', operator: '< ALL'}
{name: 'lteAll', modifier: '$lteAll', operator: '<= ALL'}
{name: 'gtAll', modifier: '$gtAll', operator: '> ALL'}
{name: 'gteAll', modifier: '$gteAll', operator: '>= ALL'}
].forEach ({name, modifier, operator}) ->
dsl[name] = modifiers[modifier] = (left, right) ->
unless left?
throw new Error "`#{name}` needs left operand"
unless right?
throw new Error "`#{name}` needs right operand"
if Array.isArray right
if name in ['in', 'nin']
if right.length is 0
throw new Error "`#{name}` with empty array as right operand"
else
# only $in and $nin support arrays
throw new TypeError "`#{name}` doesn't support array as right operand. only `in` and `nin` do!"
# not array
else
unless implementsSqlFragmentInterface right
if name in ['in', 'nin']
throw new TypeError "`#{name}` requires right operand that is an array or implements sql-fragment interface"
else
throw new TypeError "`#{name}` requires right operand that implements sql-fragment interface"
return dsl.subquery operator, left, right
###################################################################################
# and
isAnd = (x) ->
prototypes.and.isPrototypeOf x
prototypes.and = beget prototypes.base,
sql: (escape = identity) ->
parts = @_operands.map (x) ->
# we don't have to wrap ANDs inside an AND
ignoreWrap = isAnd x
normalizeSql(x, escape, ignoreWrap)
return parts.join " AND "
params: ->
params = []
@_operands.forEach (c) ->
params = params.concat c.params()
return params
dsl.and = (args...) ->
operands = flatten args
if operands.length is 0
throw new Error "`and` needs at least one operand"
operands.forEach (x) ->
unless implementsSqlFragmentInterface x
throw new Error "`and`: all operands must implement sql-fragment interface"
beget prototypes.and, {_operands: operands}
###################################################################################
# or
isOr = (x) ->
prototypes.or.isPrototypeOf x
prototypes.or = beget prototypes.base,
sql: (escape = identity) ->
parts = @_operands.map (x) ->
# we don't have to wrap ORs inside an OR
ignoreWrap = isOr x
normalizeSql(x, escape, ignoreWrap)
return parts.join " OR "
params: ->
params = []
@_operands.forEach (c) ->
params = params.concat c.params()
return params
dsl.or = (args...) ->
operands = flatten args
if operands.length is 0
throw new Error "`or` needs at least one operand"
operands.forEach (x) ->
unless implementsSqlFragmentInterface x
throw new Error "`or`: all operands must implement sql-fragment interface"
beget prototypes.or, {_operands: operands}
###################################################################################
# MAIN FACTORY
# always returns an sql-fragment.
# can be used to normalize sql strings and fragments.
# when called with a single sql fragment returns that fragment unchanged.
#
# when called with a list of
# when called with a condition-object parses that object into a fragment and returns it.
# function that recursively constructs the object graph
# of the criterion described by the arguments.
# when called with a string
criterion = (firstArg, restArgs...) ->
typeOfFirstArg = typeof firstArg
# invalid arguments?
unless 'string' is typeOfFirstArg or 'object' is typeOfFirstArg
throw new TypeError "string or object expected as first argument but #{typeOfFirstArg} given"
# raw sql string with optional params?
if typeOfFirstArg is 'string'
# make sure that no param is an empty array
emptyArrayParam = some(
restArgs
(x, i) -> {x: x, i: i}
({x, i}) -> isEmptyArray x
)
if emptyArrayParam?
throw new Error "params[#{emptyArrayParam.i}] is an empty array"
# valid raw sql !
return rawSql firstArg, restArgs
# if there is more than one argument and the first isnt a string
# map criterion over all arguments and AND them together
if restArgs.length isnt 0
return dsl.and [firstArg].concat(restArgs).map (x) -> criterion x
# FROM HERE ON THERE IS ONLY A SINGLE ARGUMENT
if implementsSqlFragmentInterface firstArg
return firstArg
# array of condition objects?
if Array.isArray firstArg
if firstArg.length is 0
throw new Error 'condition-object is an empty array'
# let's AND them together
return dsl.and firstArg.map (x) -> criterion x
# FROM HERE ON `firstArg` IS A CONDITION OBJECT
keyCount = Object.keys(firstArg).length
if 0 is keyCount
throw new Error 'empty condition-object'
# if there is more than one key in the condition-object
# cut it up into objects with one key and AND them together
if keyCount > 1
return dsl.and explodeObject(firstArg).map (x) -> criterion x
# column name
key = Object.keys(firstArg)[0]
keyFragment = dsl.escape key
value = firstArg[key]
# FROM HERE ON `firstArg` IS A CONDITION-OBJECT WITH EXACTLY ONE KEY-VALUE-MAPPING:
# `key` MAPS TO `value`
unless value?
throw new TypeError "value undefined or null for key #{key}"
if key is '$and'
return dsl.and explodeObject(value).map (x) -> criterion x
if key is '$or'
return dsl.or explodeObject(value).map (x) -> criterion x
if key is '$not'
return dsl.not criterion value
if key is '$exists'
return dsl.exists value
unless 'object' is typeof value
return dsl.eq keyFragment, value
# {x: [1, 2, 3]} is a shorthand for {x: {$in: [1, 2, 3]}}
if Array.isArray value
return dsl.in keyFragment, value
# FROM HERE ON `value` IS AN OBJECT AND NOT A NUMBER, STRING, ARRAY, ...
keys = Object.keys value
hasModifier = keys.length is 1 and 0 is keys[0].indexOf '$'
unless hasModifier
# handle other objects which are values but have no modifiers
# (dates for example) like primitives (strings, numbers)
return dsl.eq keyFragment, value
modifier = keys[0]
innerValue = value[modifier]
# FROM HERE ON `value` IS AN OBJECT WITH A `modifier` KEY AND an `innerValue`
unless innerValue?
throw new TypeError "value undefined or null for key #{key} and modifier key #{modifier}"
modifierFactory = modifiers[modifier]
if modifierFactory?
return modifierFactory keyFragment, innerValue
throw new Error "unknown modifier key #{modifier}"
###################################################################################
# EXPORTS
module.exports = criterion
# make the dsl public
for key, value of dsl
do (key, value) ->
criterion[key] = value
# make the helpers available to mesa, mohair
# and any other module that needs them
criterion.helper = helper
# make prototypes available
criterion.prototypes = prototypes