Skip to content


Subversion checkout URL

You can clone with
Download ZIP
Tree: 542c14de6f
Fetching contributors…

Cannot retrieve contributors at this time

599 lines (478 sloc) 15.558 kB
# Orpheus - a small DSL for redis
_ = require 'underscore'
async = require 'async'
os = require 'os' # ID Generation
EventEmitter = require('events').EventEmitter
inflector = require './inflector' # Inflection
commands = require './commands' # redis commands
validations = require './validations' # Validations
command_map = commands.command_map
validation_map = commands.validations
getters = commands.getters
log = console.log
# Orpheus
class Orpheus extends EventEmitter
@version = "0.2.1"
# Configuration
prefix: 'orpheus' # redis prefix, orpheus:obj:id:prop
# client - # redis client
@configure: (o) ->
@config = _.extend @config, o
# easy reference to all models
@schema = {}
# UniqueID counters
@id_counter: 0
@unique_id: -> @id_counter++
# Orpheus model extends the model
# - we can automagically detect
# the model name and qualifier.
# - you don't need to call super()
@create: ->
class OrpheusModel extends @
constructor: (@name, @q) ->
@redis = Orpheus.config.client
@prefix = Orpheus.config.prefix
@pname = inflector.pluralize @name
@model = {}
@rels = []
@rels_qualifiers = []
@validations = []
@fields = [
'str' # @str 'name'
'num' # @num 'points'
'list' # @list 'activities'
'set' # @set 'uniques'
'zset' # @zset 'ranking'
'hash' # @hash 'progress'
# Create a simple schema for all fields
@create_field f for f in @fields
# Create the model
# Creates a dynamic function that addes a field for
# a specific type 'f'. For example, `create_field('str')`
# will create the function `@str(field, options)` that
# gets the field name and its options and pushes
# them to the model schema.
# - f - the type, as string: 'str', 'num', etc
create_field: (f) ->
@[f] = (field, options = {}) ->
throw new Error("Field name already taken") if @model[field]
@model[field] =
type : f
options: options
return field
# Add mapping
map: (field) ->
if @model[field].type isnt 'str'
throw new Error("Map only works on strings")
@model[field] = true
# Add relations
has: (rel) ->
@rels.push rel
qualifier = rel.substr(0,2)
@rels_qualifiers.push qualifier
# create a relation function,
# e.g. user(12).book(15)
@[rel] = (id) ->
@["#{qualifier}_id"] = @["#{rel}_id"]= id
return @
@['un'+rel] = ->
@["#{qualifier}_id"] = @["#{rel}_id"] = null
# Add a validation function to a field
# Example:
# @validate 'name', (name) ->
# if name is 'jay' then true else 'invalid!'
validate: (key, o) ->
@validations[key] ||= []
if _.isFunction o
@validations[key].push o
# Custom validations: numericality, exclusion, inclusion
# format, etc. Validations are functions that recieve the
# field and return either true or a string as a message
if o.numericality
for k,v of o.numericality
do (k,v) =>
@validations[key].push (field) ->
unless validations.num[k].fn(field, v)
return validations.num[k].msg(field, v)
return true
if o.exclusion
@validations[key].push (field) ->
if field in o.exclusion
if o.message
return o.message field
return "#{field} is reserved."
return true
if o.inclusion
@validations[key].push (field) ->
if field in o.inclusion
return true
if o.message
return o.message field
return "#{field} is not included in the list."
if o.format
@validations[key].push (field) ->
if o.format.test(field)
return true
if o.message
return o.message field
return "#{field} is invalid."
if o.size
tokenizer = o.size.tokenizer or (field) -> field.length
for k,v of o.size
do (k,v) =>
return if k is 'tokenizer'
@validations[key].push (field) ->
len = tokenizer field
unless validations.size[k].fn(len, v)
return validations.size[k].msg(field, len, v)
return true
# return OrpheusAPI if we have the id, or it's
# a new id. otherwise, hget the id and then call
# orpheus api.
id: (id, fn) =>
if not id or _.isString(id) or _.isNumber(id)
if fn
new_model = new OrpheusAPI(id, this)
fn null, new_model,, false
new OrpheusAPI(id, this)
for k,v of id
pk = inflector.pluralize k
@redis.hget "#{@prefix}:#{@pname}:map:#{pk}", v, (err, model_id) =>
return fn err, false if err
if model_id
# existing
new_model = new OrpheusAPI(model_id, this)
fn null, new_model, model_id, false
# new
model = new OrpheusAPI(null, this)
model._add_map pk, v
fn null, model,, true
# Converts class Player to 'player'
name = @toString().match(/function (.*)\(/)[1].toLowerCase()
# Qualifier
q = name.substr(0,2)
# Add to Schema
Orpheus.schema[name] = @
model = new OrpheusModel(name, q)
# Orpheus API
class OrpheusAPI
constructor: (id, @model) ->
_.extend @, @model
# Redis multi commands
@_commands = []
@validation_errors = new OrpheusValidationErrors
# Used when retrieving information from redis
# This schema tells us how to convert the reponse
# The response schema is used to figure out how
# to convert the multi command array response to
# a model object.
# We populate the schema when the commands are
# issued.
# @_res_schema.push
# type: type # The field type: 'num'
# name: key # The field name: 'points'
# with_scores: false # used on zset commands with
# # scores, as they are converted
# # to either objects or arrays
@_res_schema = []
# New or existing id
@id = id or @_generate_id()
# If and Unless are used to discard changes
# unless certain conditions are met
@only = @when = (fn) =>
return this
# Create functions for working with the relation set
# e.g. user(15).books.sadd('dune') will map to sadd
# orpheus:us:15:books dune.
for rel in @rels
prel = inflector.pluralize rel
@[prel] = {}
for f in commands.set
do (prel, f) =>
@[prel][f] = (args..., fn) =>
@redis[f](["#{@prefix}:#{@q}:#{@id}:#{prel}"].concat(args), fn)
return this
# shorthand, use add instead of sadd
@[prel][f[1..]] = @[prel][f]
# Async functions for handling relations
do (rel, prel) =>
# user(10).books.each
# Run in parallel using async
@[prel].map = (arr = [], iter, done) =>
i = 0 arr, (item, cb) ->
iter item, cb, i++
, done
# Add all async functions.
for k,v of async when not k in ['map', 'noConflict']
@[prel][k] = v
# Add all redis commands relevant to the field and its type
# Example: model('id').ranking.zadd (zrank, zscore...)
for key, value of @model
@[key] = {}
for f in commands[value.type]
@_create_command key, value, f
# Create the Add, Set and Del commands
@operations = ['add', 'set', 'del']
for f in @operations
@_add_op f
_create_command: (key, value, f) ->
type = value.type
@[key][f] = (args...) =>
# Pop out dynamic key arguments, if any
if _.isObject(args[args.length-1]) and args[args.length-1].key
dynamic_key_args = args.pop().key
# Type Conversion
# ------------------------
# Convert types for all the commands
# in the validation map.
if validation_map[f]
if type is 'str'
if typeof args[0] isnt 'string'
if typeof args[0] is 'number'
args[0] = args[0].toString()
@validation_errors.add key,
msg: "Could not convert #{args[0]} to string"
command: f
args: args
value: args[0]
if type is 'num'
if typeof args[0] isnt 'number'
args[0] = Number args[0]
if not isFinite(args[0]) or isNaN(args[0])
@validation_errors.add key,
msg: "Malformed number"
command: f
args: args
value: args[0]
# Add multi command
@_commands.push _.flatten [f, @_get_key(key, dynamic_key_args), args]
# Response Schema
# --------------------
# If the command is a get command
if f in getters
type: type
name: key
with_scores: type is 'zset' and 'withscores' in args
# Run validation, if needed
if validation_map[f] and @validations[key]
if type in ['str', 'num']
# Regular validations
for v in @validations[key]
result = v args...
unless result is true
@validation_errors.add key,
msg: result
command: f
args: args
value: args[0]
# no need to check the extra commands
return @ unless @validation_errors.valid()
# Extra commands: mapping
@_extra_commands(key, f, args) if @model[key]
return @
# Shorthand form, incrby instead of zincrby and hincrby is also acceptable
# str: h, num: h, list: l, set: s, zset: z
@[key][f[1..]] = @[key][f] if f[0] is commands.shorthands[value.type]
# Creates the Add, Set and Del commands,
# based on the commands map.
_add_op: (f) ->
@[f] = (o) ->
for k, v of o
# Throw error on undefined attributes
if typeof @model[k] is 'undefined'
throw new Error "Orpheus :: No Such Model Attribute: #{k}"
# Add the Command. Note we won't actually
# execute any of this commands if the
# validation has failed.
type = @model[k].type
command = command_map[f][type]
@[k][command](v) # e.g. @name.hset 'abe'
return this
# Generate a unique ID for model, similiar to MongoDB
_generate_id: ->
@_new_id = true
time = "#{new Date().getTime()}"
pid =
host = 0; (host += s.charCodeAt(0) for s in os.hostname())
counter = Orpheus.unique_id()
random = Math.round(Math.random() * 1000)
_get_key: (key, dynamic_key_args) ->
# Default: orpheus:pl:15
k = "#{@prefix}:#{@q}:#{@id}"
# Add qualifiers, if the relation was set.
# e.g. orpheus:us:30:book:dune:page:13
for rel in @rels_qualifiers when @[rel+"_id"]
k += ":#{rel}:"+@[rel+"_id"]
# delete and get just need the key for
# del, hmget and hgetall
return k unless key
type = @model[key].type
# generate a new key name, if it has a
# dynamic key function.
if key and @model[key].options.key
key = @model[key].options.key.apply(this, dynamic_key_args)
if type is 'str' or type is 'num'
# orpheus:us:15:name somename
return [k, key]
# orpheus:us:15:somelist
return "#{k}:#{key}"
_add_map: (field, key) ->
@_commands.push ['hset', "#{@prefix}:#{@pname}:map:#{field}", key, @id]
# Extra commands
_extra_commands: (key, command, args) ->
# Map stuff, e.g.
# orpheus:users:map:fb_ids
if @model[key] and command is 'hset'
pkey = inflector.pluralize key
@_add_map pkey, args[0]
# Empties the object to be reused
flush: ->
@_commands = []
@_res_schema = []
# deletes the model.
delete: (fn) ->
# flush commands and validations
hdel_flag = false # no need to delete a hash twice
for key, value of @model
type = value.type
if type is 'str' or type is 'num'
unless hdel_flag
hdel_flag = true
@_commands.push ['del', @_get_key()]
@_commands.push ['del', @_get_key(key)]
@exec fn
# get public information only
get: (fn) ->
for key, value of @model
type = value.type
switch type
when 'str', 'num' then @[key].get()
when 'list' then @[key].range 0, -1
when 'set' then @[key].members()
when 'zset' then @[key].range 0, -1, 'withscores'
when 'hash' then @[key].hgetall()
@exec fn
# Converts the results back from the multi
# array values we get from node redis. The response
# schema, @_res_schema, is populated when we
# add commands, and now, based on the types it
# stored, we convert everything back to an object.
_create_getter_object: (res) =>
new_res = {}
for s,i in @_res_schema
# Convert numbers from their string representation
if s.type is 'num'
new_res[] = Number(res[i])
# Convert zsets with scores to a key->value hash
else if s.type is 'zset' and s.with_scores
new_res[] = {}
for member,index in res[i] by 2
new_res[][member] = Number res[i][index+1]
# Add everything else as attributes
new_res[] = res[i]
# If the field is empty - undefined, null, {}, []
# then user the field's default or delete the field
# if there's no default.
field = new_res[]
if _.isNull(field) or _.isUndefined(field) or (_.isObject(field) and _.isEmpty(field))
if _.isUndefined @model[].options.default
then delete new_res[]
else new_res[] = @model[].options.default
return new_res
err: (fn) ->
@error_func = fn || -> # noop
return @
# execute the multi commands
exec: (fn) ->
fn ||= -> # noop
# Check for validation errors
unless @validation_errors.valid()
if @error_func
then return @error_func @validation_errors, @id
else return fn @validation_errors, null, @id
.exec (err, res) =>
# If the request was just for getting information,
# convert the results back based on the response
# scehma.
if @_res_schema.length and not err
if @_res_schema.length is @_commands.length
res = @_create_getter_object res
# Check whether we should call the error or function
# or the execute function, and if we call the execute
# function with which parameters.
if @error_func
if err
err.time = new Date()
err.level = 'ERROR'
err.type = 'redis'
err.msg = 'Failed Multi Execution'
@_errors.push err
Orpheus.emit 'error', err
@error_func err
if @_new_id then fn res, @id
else fn res
if @_new_id then fn err, res, @id
else fn err, res
# Orpheus Validation errors
class OrpheusValidationErrors
constructor: ->
@type = 'validation'
@errors = {}
valid: ->
_.isEmpty @errors
invalid: ->
add: (field, error) ->
# Add date, useful for logging = new Date().getTime()
@errors[field] || = []
@errors[field].push error
empty: ->
@errors = {}
toResponse: ->
# 400 - Bad Request
# See
obj = {status: 400, errors: {}}
# Add the validation error messages
for k,v of @errors
obj.errors[k] = (m.msg for m in v)
# Export
module.exports = Orpheus
Jump to Line
Something went wrong with that request. Please try again.