miércoles, diciembre 12, 2012

Query parser para servicios Web API


Desde hace unos días estuve buscando la forma de pasar a un servicio Rest (de Web API) una expresión que pudiera ser útil para consultar tablas en forma dinámica (no se conoce a priori su estructura).

Las expresiones que deseaba pasarle al parser eran del tipo:
GET /Service/Customers?$where=Name eq ‘ACME’

Esto ayuda a independizarse de la base de datos, evitar SQL injection y brindar una api de consumo amigable.

Todas las soluciones que pude encontrar se basan en parsers tipados, solución que quizás funcione para el 90%, pero a mi me tocó estar en el porcentaje restante.

Ejemplo de parser está de OData para Web API
http://blogs.msdn.com/b/alexj/archive/2012/12/06/parsing-filter-and-orderby-using-the-odatauriparser.aspx

Linq2Rest
https://bitbucket.org/jjrdk/linq2rest

Tuve la suerte de encontrarme con la referencia de operadores usada en MongoDB
http://docs.mongodb.org/manual/reference/operators/

Donde usando JSON se puede pasar una expresión de filtro estilo:
where={ $and: [{'gid': {'$gt': 1}}, {'layer': 0}] }

He aquí el código, parece simple, y lo es, pero anda :)



/*
 * QueryParser
 * 
 * Autor: Felixls
 * Fecha: Diciembre 2012
 */
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using Sample.Models;

using Newtonsoft.Json.Linq;

namespace Sample.Controllers
{
    /*
     * Parser de consultas con sintaxis de MongoDB
     * http://docs.mongodb.org/manual/reference/operators/
     * 
     * Ejemplos:
            ?where={"age": {"$gt": 20}}
            { 'gid': {'$eq': 1}}
            { 'gid': 1}
            { 'gid': {'$gt': 1}, 'layer': 0}
            { $and: [{'gid': {'$gt': 1}}, {'layer': 0}] }
            { $or: [{'gid': {'$gt': 1}}, {'layer': 0}] }
            { 'handle': 48, $and: [{'gid': {'$gte': 1}}, {'layer': 0}] }
            { 'handle': 48, $or: [{'gid': {'$gte': 1}}, {'layer': 0}] }
            { $and: [{'gid': {'$gt': 1}}, {$or: [{'layer': 0}, {'layer': {'$eq': 1}}]}] }
            { 'gid': {'$gt': 1}, $or: [{'layer': 0}, {'layer': {'$eq': 1}}] }
            { 'gid': {'$in': [1, 5]} }
            { 'fecha': {'$in': ['2012-12-09', '2012-12-10']} }
            { 'fecha': {'$in': ['2012-12-09', '2012-12-10']}, 'tipomov': 2 }
            { 'fecha': {'$in': ['2012-12-09', '2012-12-10']}, $or: {'tipomov': 2} }
            { $and: [{'gid': {'$gt': 1}}, {$or: [{'layer': 0}, {'layer': {'$eq': 1}}]}] }
            { 'gid': {'$gt': 1}, $or: [{'layer': 0}, {'layer': {'$eq': 1}}] }
            { 'gid': {'$in': [1, 5]} }
            { 'fecha': {'$in': ['2012-12-09', '2012-12-10']} }
            { 'fecha': {'$nin': ['2012-12-09', '2012-12-10']}, 'tipomov': 2 }
            { 'fecha': {'$in': ['2012-12-09', '2012-12-10']}, $or: {'tipomov': 2} }
            { 'codigo': {'$gt': 'L49'}, $or: [{'tipomov': 3}, {'tipomov': {'$lt': 2}}, {$and: {'fecha': {'$in': ['2012-12-09', '2012-12-10']}}}] }
            { 'codigo': {'$gt': 'L49'}, $or: [{'tipomov': 3}, {'tipomov': {'$lt': 2}}], $and: {'fecha': {'$nin': ['2012-12-09', '2012-12-10']}} }

     * Operadores soportados

            $eq  =
            $ne  !=
            $lt  <
            $lte <=
            $gt  >
            $gte >=
            $in      { qty: { $in: [ 5, 15 ] } }
            $nin !$in
            $and     { $and: [ { price: 1.99 }, { qty: { $lt: 20 } }, { sale: true } ] }
            $and implicito    { price: 1.99, qty: { $lt: 20 } , sale: true }
            $or      { price:1.99, $or: [ { qty: { $lt: 20 } }, { sale: true } ] }
         
     * NO SOPORTADOS
            $all, $nor, $not.

            //TODO: Geoespaciales (a futuro soportadas) 
            $near    { location: { $near: [100,100] } }
            $bbox    { loc: { $within: { $box: [ [0,0], [100,100] ] } } }
            $within  { location: { $within: { shape } } }
                    { location: { $within: { $box: [[100,0], [120,100]] } } }
               { location: { $within: { $center: [ center, radius } } }
               { location: { $within: { $box: [[100,120], [100,100], [120,100], [240,200]] } } } 
            $polygon { loc: { $within: { $polygon: [ [0,0], [3,6], [6,0]  ] } } }
            $center  { location: { $within: { $center: [ [0,0], 10 ] } } } 
            $maxdistance { location: { $near: [100,100], $maxDistance: 10 } }
            $nearSphere { loc: { $nearSphere: [0,0] } }
            $centerSphere { loc: { $centerSphere: { [0,0], 10 / 3959 } } } 
     */
    public class QueryParser
    {
        private List atts;
        private dynamic root;
        private string result = "";
        private int state = 0;
        private int level = 0;

        public QueryParser(string s, List atts)
        {
            this.atts = atts;
            root = JObject.Parse(s);
            
            result = " where ";
            state = 0;
            level = 0;

            recurse(root);
        }

        private void recurse(dynamic obj)
        {
            string p;
            
            foreach (var current in obj)
            {
                p = ParseAttribute(current);
                if (p != null)
                {
                    if (level == 1)
                    {
                        if (state == 0)
                            result += " and ";
                        if (state == 1)
                            result += " or ";
                    }
                    result += p;
                    level = 1;
                }
                else
                {
                    if (current.Name == "$or")
                        LogicalOperator(current, 1);
                    else if (current.Name == "$and")
                        LogicalOperator(current, 0);
                    else
                        throw new Exception("Syntax error");
                }
            }
        }

        private void LogicalOperator(dynamic current, int theState)
        {
            if (current.Value is JArray)
            {
                if (level == 1)
                    result += " and ";
                state = theState;
                level = 0;
                result += "(";
                for (int i = 0; i < current.Value.Count; i++)
                {
                    if (i == 1)
                        level = 1;
                    recurse(current.Value[i]);
                }
                result += ")";
            }
            else
            {
                state = theState;
                recurse(new List(current.Value));
            }
        }

        public string Where()
        {
            return result;
        }

        private string ParseAttribute(dynamic obj)
        {
            foreach (FeatureAttribute att in this.atts)
            {
                if (obj.Name == att.Name)
                {
                    var op = obj.Value;
                    var s = "";
                    if (op is JValue)
                        s += att.Name + "='" + op + "'";
                    else if (op["$eq"] != null)
                        s += att.Name + "='" + op["$eq"] + "'";
                    else if (op["$ne"] != null)
                        s += att.Name + "!='" + op["$ne"] + "'";
                    else if (op["$lt"] != null)
                        s += att.Name + "<'" + op["$lt"] + "'";
                    else if (op["$lte"] != null)
                        s += att.Name + "<='" + op["$lte"] + "'";
                    else if (op["$gt"] != null)
                        s += att.Name + ">'" + op["$gt"] + "'";
                    else if (op["$gte"] != null)
                        s += att.Name + ">='" + op["$gte"] + "'";
                    else if (op["$in"] != null)
                    {
                        JArray rango = op["$in"];
                        if (rango.Count() == 2)
                            s += att.Name + " between '" + rango[0] + "' and '" + rango[1] + "'";
                    }
                    else if (op["$nin"] != null)
                    {
                        JArray rango = op["$nin"];
                        if (rango.Count() == 2)
                            s += att.Name + " not between '" + rango[0] + "' and '" + rango[1] + "'";
                    }
                    else
                    {
                        throw new Exception("Syntax error");
                    }
                    return s;
                }
            }
            return null;
        }
    }
}