odata-resource
Version:
REST + OData + Mongoose/MongoDb
524 lines (499 loc) • 18.4 kB
JavaScript
var OdataCommon = function(method) {
this.method = method;
};
OdataCommon.prototype.visit = function(visitor) {
if(this.method) {
if(typeof(visitor[this.method]) !== 'function') {
throw 'Expression visitor does not implement \''+this.method+'\'';
}
visitor[this.method](this);
} else {
throw 'No visitor method defined';
}
};
var OdataMethodCall = function(args,method) {
this.args = args;
OdataCommon.apply(this,[method]);
};
OdataMethodCall.prototype = new OdataCommon();
OdataMethodCall.prototype.getMethodName = function() { return this.method; };
OdataMethodCall.prototype.getArguments = function() { return this.args; };
var OdataBinary = function(lhs,rhs,op) {
this.lhs = lhs;
this.rhs = rhs;
OdataCommon.apply(this,[op]);
};
OdataBinary.prototype = new OdataCommon();
OdataBinary.prototype.getLHS = function() { return this.lhs; };
OdataBinary.prototype.getRHS = function() { return this.rhs; };
var OdataLiteral = function(value,method) {
this.value = value;
OdataCommon.apply(this,[method]);
};
OdataLiteral.prototype = new OdataCommon();
OdataLiteral.prototype.getValue = function() { return this.value; };
var OdataStringLiteral = function(value) {
OdataLiteral.apply(this,[value,'string_lit']);
}
OdataStringLiteral.prototype = new OdataLiteral();
var OdataDateLiteral = function(value) {
OdataLiteral.apply(this,[value,'date_lit']);
}
OdataDateLiteral.prototype = new OdataLiteral();
var OdataNumberLiteral = function(value) {
OdataLiteral.apply(this,[value,'number_lit']);
}
OdataNumberLiteral.prototype = new OdataLiteral();
var OdataBooleanLiteral = function(value) {
OdataLiteral.apply(this,[value,'boolean_lit']);
}
OdataBooleanLiteral.prototype = new OdataLiteral();
var OdataProperty = function(prop) {
this.property = prop;
OdataCommon.apply(this,['property']);
};
OdataProperty.prototype = new OdataCommon();
OdataProperty.prototype.getPropertyName = function() { return this.property; };
var OdataBoolCommonExpression = function() {
this.sub_expressions = [];
OdataCommon.apply(this,[null/* operator gets set later */]);
};
OdataBoolCommonExpression.prototype = new OdataCommon();
OdataBoolCommonExpression.prototype.isEmpty = function() { return this.sub_expressions.length === 0; };
OdataBoolCommonExpression.prototype.addSubExpression = function(sub) { this.sub_expressions.push(sub); };
OdataBoolCommonExpression.prototype.visit = (function(superFunc){
return function(visitor) {
var self = this;
// wrapping a single simple expression in an and/or
// when there's nothing to and/or it with.
// e.g. "name eq 'foo'"
if(!self.method && self.sub_expressions.length === 1) {
self.sub_expressions[0].visit(visitor);
return;
}
superFunc.apply(self,arguments);
};
})(OdataBoolCommonExpression.prototype.visit);
var WHITESPACE = 0,
QUOTED_STRING = 1,
WORD = 2,
NUMBER = 3,
OPEN_PAREN = 4,
CLOSE_PAREN = 5,
SYMBOL = 6,
DATE = 7,
COMPARISONS = ['eq','ne','lt','le','gt','ge'],
LOGICALS = ['and','or'],
METHODS = ['contains','startswith','endswith','in','notin'],
IS_ALPHA_NUM = /^[a-z0-9_]+$/i, // added _ since it's common to prefix mongo properties with _
IS_NUM = /^[0-9]+$/
IS_DATE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
var OdataExpressionParser = function() {
};
OdataExpressionParser.prototype.handleSubexpression = function(tokens) {
var bce = new OdataBoolCommonExpression(),
token,method,args,word1,comparison,word2;
if(tokens.length) {
while(true) {
if(!(token = this.pop(tokens))) {
break;
}
// first pass, no support for or nothing fancy just a list of ands
if(!bce.isEmpty()) {
if(!this.isLogical(token)) {
throw 'Expected logical operator but received \''+token.token+'\'';
}
if(!bce.method) {
bce.method = token.token;
} else if(bce.method !== token.token) {
throw 'Mixing of logical operators without grouping parenthesis not supported';
}
token = this.pop(tokens,true,true);
}
if(token instanceof OdataBoolCommonExpression) {
bce.addSubExpression(token);
continue;
}
if(token.type !== WORD) {
throw 'Expected word but got \''+token.token+'\'';
}
if(this.isMethod(token)) {
method = token;
this.pop(tokens,true,true,OPEN_PAREN);
args = [];
while(true) {
token = this.pop(tokens,true,true);
args.push(this.castSimple(token));
token = this.pop(tokens,true,true);
if(token.type === CLOSE_PAREN) {
break;
} else if(token.type !== SYMBOL || token.token !== ',') {
throw 'Missing separator between method arguments';
}
}
if(args.length === 0) {
throw 'Missing method arguments';
}
bce.addSubExpression(new OdataMethodCall(args,method.token));
} else {
word1 = token;
comparison = this.pop(tokens,true,true);
if(!this.isComparison(comparison)) {
throw 'Expected comparison but received \''+comparison.token+'\'';
}
word2 = this.pop(tokens,true,true);
bce.addSubExpression(new OdataBinary(this.castSimple(word1),this.castSimple(word2),comparison.token));
}
}
}
return bce;
};
OdataExpressionParser.prototype.parse = function(filter) {
var tokens = this.tokenize(filter),
i,tok,
cParen,subExpr;
// iterate over the expression looking for nested parenthesized
// when found replace their tokens with their parsed expression
// repeat until no nested parenthesized expressions are found
do {
subExpr = null;
for(i = tokens.length-1; i >= 0; i--) {
i = this._skipHandledExpressions(tokens,i);
if(i <= 0) {
break;
}
tok = tokens[i];
if(this.isMethod(tok)) {
i = this._skipMethod(tokens,i);
if(i === 0) {
// method ends expression
break;
}
continue;
}
if(tok.type === OPEN_PAREN) {
if((cParen = this._endOfInnermostSubExpression(tokens,i)) >= 0) {
subExpr = this.handleSubexpression(tokens.slice(cParen+1,i));
// replace the tokens with the subExpr
tokens.splice(cParen,(i-cParen)+1,subExpr);
break;
}
}
}
} while(subExpr);
return this.handleSubexpression(tokens);
};
OdataExpressionParser.prototype._skipHandledExpressions = function(tokens,i) {
var tok;
while(i >= 0) {
tok = tokens[i];
if(tok instanceof OdataBoolCommonExpression) {
i--;
} else {
break;
}
}
return i;
};
OdataExpressionParser.prototype._skipMethod = function(tokens,methodIndex) {
var i = methodIndex-1;
if(i < 0) {
throw 'Unexpected end of expression';
}
var tok = tokens[i];
if(tok.type !== OPEN_PAREN) {
throw 'Missing open parenthesis on method';
}
while(i >= 0 && tok.type !== CLOSE_PAREN) {
tok = tokens[--i];
}
return i;
};
OdataExpressionParser.prototype._endOfInnermostSubExpression = function(tokens,openParen) {
var i,tok;
for(i = openParen-1; i >= 0; i--) {
i = this._skipHandledExpressions(tokens,i);
tok = tokens[i];
if(this.isMethod(tok)) {
i = this._skipMethod(tokens,i);
if(i === 0) {
throw 'Unexpected end of expression';
}
continue;
}
if(tok.type === OPEN_PAREN) {
return -1; // another nested expression
}
if(tok.type === CLOSE_PAREN) {
return i; // hit end of expression before close
}
}
return -1;
};
OdataExpressionParser.prototype.pop = function(tokens,skip_white,complain,expectedType) {
if(typeof(skip_white) === 'undefined') { skip_white = true; }
if(typeof(complain) === 'undefined') { complain = false; }
var token = tokens.pop();
if(!token) {
return token;
}
while(skip_white && token.type === WHITESPACE) {
token = tokens.pop();
if(!token) {
break;
}
}
if(!token && complain) {
throw 'Expected additional token but found none.';
}
if(expectedType && token && expectedType != token.type) {
throw 'Expected '+ expectedType + ' but found '+token.type;
}
return token;
};
OdataExpressionParser.prototype.castSimple = function(token) {
switch(token.type) {
case QUOTED_STRING:
return new OdataStringLiteral(token.token);
case NUMBER:
return new OdataNumberLiteral(parseInt(token.token));
case WORD:
if(token.token == 'true' || token.token == 'false') {
return new OdataBooleanLiteral(token.token == 'true');
}
if(token.token === 'null') {
return new OdataStringLiteral(null);
}
return new OdataProperty(token.token);
case DATE:
return new OdataDateLiteral(new Date(token.token));
default:
throw 'Unexpected token '+ token.token + ' (expected simple).';
}
};
OdataExpressionParser.prototype.isLogical = function(token) {
return token.type === WORD && LOGICALS.indexOf(token.token) !== -1;
};
OdataExpressionParser.prototype.isMethod = function(token) {
return token.type === WORD && METHODS.indexOf(token.token) !== -1;
};
OdataExpressionParser.prototype.isComparison = function(token) {
return token.type === WORD && COMPARISONS.indexOf(token.token) !== -1;
};
OdataExpressionParser.prototype.readDigits = function(val,start) {
var end = start,
len = val.length;
while(end < len && !isNaN(parseInt(val.charAt(end)))) {
end++;
}
return end;
};
OdataExpressionParser.prototype.readWord = function(val,start,other_valid) {
var end = start,
len = val.length,c;
while(end < len) {
c = val.charAt(end);
if(IS_ALPHA_NUM.test(c) || c === '/' || c === '_' || c === '.' || c === '*' || c === '-' || c === ':') {
end++;
} else {
break;
}
}
return end;
};
OdataExpressionParser.prototype.readQuotedString = function(val,start) {
var end = start,
len = val.length;
while(val.charAt(end) !== '\'' || (end < (len-1) && val.charAt(end+1) === '\'')) {
end += val.charAt(end) !== '\'' ? 1 : 2;
if(end > len) {
throw 'Encountered unterminated quoted string in filter \''+val+'\'';
}
}
return end;
};
OdataExpressionParser.prototype.readWhitespace = function(val,start) {
var end = start,
len = val.length;
while(end < len && val.charAt(end) === ' ') {
end++;
}
return end;
};
OdataExpressionParser.prototype.tokenize = function(filter) {
var tokens = [],
len = filter.length,
current = 0,
end = 0,c,w;
while(true) {
if(current >= len) {
break;
}
c = filter.charAt(current);
if(c === ' ') {
end = this.readWhitespace(filter,current);
tokens.push({type: WHITESPACE,token: filter.substr(current,(end-current))});
current = end;
} else if (c === '\'') {
end = this.readQuotedString(filter,current+1);
tokens.push({type: QUOTED_STRING,token: filter.substr((current+1),(end-current)-1)});
current = end+1;
} else if (IS_NUM.test(c)) {
// a date starts with a number, test if this is a date
end = this.readWord(filter,current);
w = filter.substr(current,(end-current))
if(IS_DATE.test(w)) {
tokens.push({type: DATE, token: w});
current = end;
} else { // just a number
end = this.readDigits(filter,current);
tokens.push({type: NUMBER, token:parseInt(filter.substr(current,(end-current)))});
current = end;
}
} else if(IS_ALPHA_NUM.test(c) || c === '*') {
end = this.readWord(filter,current);
tokens.push({type: WORD,token:filter.substr(current,(end-current))});
current = end;
} else if (c === '(') {
tokens.push({type: OPEN_PAREN, token: '('});
current++;
} else if (c === ')') {
tokens.push({type: CLOSE_PAREN, token: ')'});
current++;
} else if (',.+='.indexOf(c) !== -1) {
tokens.push({type: SYMBOL, token: c});
current++;
} else {
for(var i = tokens.length-1; i >= 0; i--) {
console.log(`token[${i}]`,tokens[i]);
}
throw 'Unexpected character \''+c+'\' when parsing filter \''+filter+'\'';
}
}
return tokens.reverse();
};
// will build a mongoose query object in the query property.
var MongooseVisitor = function() {
this.query = {};
this.curBoolConditions = null;
};
MongooseVisitor.prototype.condition = function(cond) {
if(this.curBoolConditions) {
this.curBoolConditions.push(cond);
} else {
this.query = cond;
}
};
MongooseVisitor.prototype.simpleCondition = function(op,prop,value) {
var c = {},
match = {};
match[op] = value;
c[prop] = match;
this.condition(c);
};
// OData doesn't have an 'in' method but it's useful...
// in(<property>,<literal>,<literal>...)
MongooseVisitor.prototype.in = function(method) {
var args = method.getArguments(),
prop = args[0].getPropertyName(),
in_args = args.slice(1).map(function(arg) {
return arg.getValue();
});
this.simpleCondition('$in',prop,in_args);
};
MongooseVisitor.prototype.notin = function(method) {
var args = method.getArguments(),
prop = args[0].getPropertyName(),
in_args = args.slice(1).map(function(arg) {
return arg.getValue();
});
this.simpleCondition('$nin',prop,in_args);
};
MongooseVisitor.prototype.regexEscape = function(str) {
return str.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&");
};
MongooseVisitor.prototype.startswith = function(method) {
var args = method.getArguments(),
prop = args[0].getPropertyName(),
val = args[1].getValue();
this.simpleCondition('$regex',prop,new RegExp('^'+this.regexEscape(val)));
};
MongooseVisitor.prototype.endswith = function(method) {
var args = method.getArguments(),
prop = args[0].getPropertyName(),
val = args[1].getValue();
this.simpleCondition('$regex',prop,new RegExp(this.regexEscape(val)+'$'));
};
MongooseVisitor.prototype.contains = function(method) {
var args = method.getArguments(),
prop = args[0].getPropertyName(),
val = args[1].getValue();
this.simpleCondition('$regex',prop,new RegExp(this.regexEscape(val)));
};
MongooseVisitor.prototype.eq = function(expr) {
this.simpleCondition('$eq',expr.getLHS().getPropertyName(),expr.getRHS().getValue());
};
MongooseVisitor.prototype.ne = function(expr) {
this.simpleCondition('$ne',expr.getLHS().getPropertyName(),expr.getRHS().getValue());
};
MongooseVisitor.prototype.gt = function(expr) {
this.simpleCondition('$gt',expr.getLHS().getPropertyName(),expr.getRHS().getValue());
};
MongooseVisitor.prototype.lt = function(expr) {
this.simpleCondition('$lt',expr.getLHS().getPropertyName(),expr.getRHS().getValue());
};
MongooseVisitor.prototype.ge = function(expr) {
this.simpleCondition('$gte',expr.getLHS().getPropertyName(),expr.getRHS().getValue());
};
MongooseVisitor.prototype.le = function(expr) {
this.simpleCondition('$lte',expr.getLHS().getPropertyName(),expr.getRHS().getValue());
};
MongooseVisitor.prototype.logical = function(op,binary) {
var self = this,
conditions = [],
c = {};
c[op] = conditions;
self.condition(c);
var prevBoolConditions = self.curBoolConditions;
self.curBoolConditions = conditions;
binary.sub_expressions.forEach(function(expr) {
expr.visit(self);
});
self.curBoolConditions = prevBoolConditions;
};
MongooseVisitor.prototype.and = function(binary) {
this.logical('$and',binary);
};
MongooseVisitor.prototype.or = function(binary) {
this.logical('$or',binary);
};
MongooseVisitor.prototype.string_lit = function(lit) {};
MongooseVisitor.prototype.date_lit = function(lit) {};
MongooseVisitor.prototype.number_lit = function(lit) {};
MongooseVisitor.prototype.boolean_lit = function(lit) {};
MongooseVisitor.prototype.property = function(prop) {};
module.exports = function(query,filter) {
var visitor = new MongooseVisitor();
(new OdataExpressionParser()).parse(filter).visit(visitor);
query.where(visitor.query);
};
/*
var visitor = new MongooseVisitor(),
parser = new OdataExpressionParser(),
filter = `(name eq 'Bob' or (name eq 'Fred' and age eq 10 and (startswith(foo,'bar') or this eq 2))) and stars ne 2`,
stringify = function(o) {
console.log(JSON.stringify(o,null,' '));
};
filter='in(x,1,2,3) and name eq \'foo\'';
filter=`((type eq 'project' and deliverable eq 'map') or (type eq 'product' and type eq 'map')) and category eq 'foo'`;
filter=`name eq 'foo' or name eq 'bar'`;
filter=`(name eq 'foo' and age gt 10) or (name eq 'bar' and age gt 20)`
filter=`name eq 'foo'`
filter=`date eq 2014-06-23T03:30:00.000Z`
bce = parser.parse(filter);
console.log(`-----BCE---- "${filter}"`);
stringify(bce);
bce.visit(visitor);
console.log(`-----Mongo Query---- "${filter}"`);
stringify(visitor.query);
*/