I don't visit nor ask things on here too much, so I hope this question is allowed.
I've been assigned a task, to implement a REST service, where the GET endpoint would basically work as an OData service. But not exactly. I tried convincing the project owner than just implementing a straight on OData service would be the better solution, but I was shot down.
I've attempted this already using brute force string parsing, and a very large cumbersome switch statement, but it's messy. And I'm wondering what would be a better approach for this type of thing.
Here are the specifics:
GET /store?query=<expr>
assume <expr> is URLEncoded
EQUAL(field, val)
field = table field name
val = can be either a string or an int depending on the field
AND(expr1, expr2)
expr1/exprr2 = any of valid functions, such as EQUAL(), NOT(), GREATER_THAN(), and LESS_THAN()
OR(expr1, expr2)
expr1/exprr2 = any of valid functions, such as EQUAL(), NOT(), GREATER_THAN(), and LESS_THAN()
NOT(expr)
expr1 = any of valid functions, such as EQUAL(), GREATER_THAN(), and LESS_THAN()
GREATER_THAN(field, val)
field = table field name (must be an int field)
val = int value to look for
LESS_THAN(field, val)
field = table field name (must be an int field)
val = int value to look for
Examples
Assume a List<StoreData>
for the data itself:
public class StoreData
{
[Required]
public string id { get; set; }
[Required]
public string title { get; set; }
[Required]
public string content { get; set; }
[Required]
public int views { get; set; }
[Required]
public int timestamp { set; get; }
}
GET /store?query=EQUAL(id, "100")
-> should fail, improper parameter type
GET /store?query=EQUAL(id, 100)
-> should be successful
GET /store?query=EQUAL(views, 5)
-> should be successful
GET /store?query=EQUAL(views, "190)
-> should fail, improper parameter type
GET /store?query=GREATER_THAN(views, 500)
-> should be successful
GET /store?query=GREATER_THAN(views, "500")
-> should fail, improper parameter type
GET /store?query=AND(EQUAL(id,"666"), EQUAL(views,100))
-> should be successful
GET /store?query=AND(NOT(EQUAL(id,"666")),EQUAL(views,100))
-> should be successful
GET /store?query=AND(EQUAL(id,"666"),GREATER_THAN(views,0))
-> should be successful
GET /store?query=AND(GREATER_THAN(views,"100"), EQUAL(id,"666"))
-> should fail, improper parameter type
GET /store?query=OR(EQUAL(id,"100"),EQUAL(id,"666"))
-> should be successful
GET /store?query=OR(EQUAL(id,100),GREATER_THAN(views,66))
-> should be successful
GET /store?query=NOT(EQUAL(id,"666"))
-> should be successful
GET /store?query=EQUAL(title, "Title 756")
-> should be successful
GET /store?query=EQUAL(TITLE, "TITLE 756")
-> should be successful, just ensuring it's case insensitive
GET /store?query=EQUAL(blah, 100)
-> should fail, unrecognized field
Other than brute force, my thought was some type of lexical parser, but I know next to nothing about how to proceed.
Someone asked about the brute force parsing I've already done, so here it is... the main parsing logic
// Ideally a whole expression parsing engine should be built, but I'm on a limited time frame
// After working all night, and struggling on the OR, I added DynamicLinq thru NuGet, which made the OR
// sooooooooo much easier. I really wish I had to time to re-write this entire thing using that DynamicLinq
// for everything... but on a time constraint here. I wonder if the "customer" would accept a delay? :)
public List<StoreData> GetData(string sQueryString)
{
int iPos1 = sQueryString.IndexOf('(');
int iPos2 = sQueryString.LastIndexOf(')');
string firstAction = sQueryString.Substring(0, iPos1);
// ideally if this were a database, this would be handled completely different with a EF dbContext
// but this list is relatively small. In a production app, monitor this performance.
// we build is as an IQueryable so we can add to the WHERE condition before running the SQL
var theData = Startup.MockData.AsQueryable();
// short cut for these first few types...
// for functions that are not nested, _params.Length should equal 2
// nested functions will produce _params.Length would be more, depending on the nesting level
string[] _params = sQueryString.Substring(iPos1 + 1, (iPos2 - iPos1) - 1).Split(',');
// since we're starting with the easy direct field functions first, we know there'll be 2 elements in _params
string fieldName = _params[0].Trim();
string fieldValue = _params[1].Trim();
int iVal = 0;
// EQUAL(field, val)
// AND(expr1, expr2)
// AND(EQUAL(field, val), EQUAL(field, val))
// AND(EQUAL(field, val), NOT(EQUAL(field, val)))
// AND(EQUAL(field, val), GREATER_THAN(field, val))
// OR(expr1, expr2)
// OR(EQUAL(field, val), EQUAL(field, val))
// OR(EQUAL(field, val), NOT(EQUAL(field, val)))
// OR(EQUAL(field, val), GREATER_THAN(field, val))
// NOT(expr)
// GREATER_THAN(field, val)
// LESS_THAN(field, val)
//
// functions with expression parameters (expr<x>) can be any of the functions directly operating on fields
switch (firstAction.ToUpper())
{
// these first 3 should be the easiest to deal with initially since the operate
// directly on fields and values as opposed to an expression
case "EQUAL":
theData = HandleEQUAL(theData, fieldName, fieldValue);
break;
case "GREATER_THAN":
case "LESS_THAN":
iVal = -1;
if(!int.TryParse(fieldValue.Trim(), out iVal))
{
throw new Exception($"Malformed Query Parameter: {fieldName} parameter must be an int type");
}
if (iVal == -1)
{
}
if(!_intProperties.Any(x => x.ToUpper() == fieldName.ToUpper()))
{
throw new Exception($"Malformed Query Parameter: {fieldName} parameter must be an int type");
}
if(firstAction.ToUpper() == "GREATER_THAN")
{
theData = theData.Where(x => ((int)x.GetValueByName(fieldName)) > iVal);
}
else if (firstAction.ToUpper() == "LESS_THAN")
{
theData = theData.Where(x => ((int)x.GetValueByName(fieldName)) < iVal);
}
break;
// I'm not sure how many different permutations there can be with this command
// but for now only handling EQUAL as the parameter. It doesn't make much sense to
// handle a NOT(GREATER_THAN()) or NOT(LESS_THAN()) because they have mutually exclusive
// selection commands already. a NOT(GREATER_THAN()) is basically the same as LESS_THAN(),
// and a NOT(LESS_THAN()) is a GREATER_THAN()
//
// do I need to worry about a NOT(AND(EQUAL(id, "1"), NOT(EQUAL(views, 666))) ?
// or how about NOT(NOT(EQUAL(id, "100")) ??
case "NOT":
fieldName = fieldName.Replace("EQUAL(", "", StringComparison.OrdinalIgnoreCase).Trim();
fieldValue = fieldValue.Replace(")", "").Trim();
theData = theData.Where(x => ((string)x.GetValueByName(fieldName)) != fieldValue.Replace("\"", ""));
break;
// these are the painful ones, with many permutations
// these 2 can be combined in many different statements, using a mixture of any of the top level commands
// AND(EQUAL(id,"666"),EQUAL(views,100)) - shouldn't be too difficult
// AND(NOT(EQUAL(id, "666")), EQUAL(views, 100)) - ARGH!!!
// AND(EQUAL(id,"666"),GREATER_THAN(views,100)) -
case "AND":
#region Handles EQUAL() and NOT(EQUAL())
// 1st expression in the AND(expr1, expr2) function call
if (_params[0].Contains("EQUAL(", StringComparison.OrdinalIgnoreCase))
{
fieldName = _params[0].Replace("EQUAL(", "", StringComparison.OrdinalIgnoreCase).Trim();
fieldValue = _params[1].Replace(")", "").Trim();
if(_stringProperties.Any(x => x.ToUpper() == fieldName.ToUpper()))
{
if (_params[0].Contains("NOT(", StringComparison.OrdinalIgnoreCase))
{
theData = HandleNOT_EQUAL(theData, fieldName, fieldValue);
}
else
{
theData = HandleEQUAL(theData, fieldName, fieldValue);
}
}
else if (_intProperties.Any(x => x.ToUpper() == fieldName.ToUpper()))
{
iVal = -1;
if(!Int32.TryParse(fieldValue.Trim(), out iVal))
{
throw new Exception($"Malformed Query Parameter: {fieldName} parameter must be an int type");
}
if (_params[0].Contains("NOT(", StringComparison.OrdinalIgnoreCase))
{
theData = theData.Where(x => ((int) x.GetValueByName(fieldName)) != iVal);
}
else
{
theData = theData.Where(x => ((int)x.GetValueByName(fieldName)) == iVal);
}
}
}
// 2nd expression in the AND(expr1, expr2) function call
if (_params[2].Contains("EQUAL(", StringComparison.OrdinalIgnoreCase))
{
fieldName = _params[2].Replace("EQUAL(", "", StringComparison.OrdinalIgnoreCase).Trim();
fieldValue = _params[3].Replace(")", "").Trim();
if(_stringProperties.Any(x => x.ToUpper() == fieldName.ToUpper()))
{
if (_params[2].Contains("NOT(", StringComparison.OrdinalIgnoreCase))
{
theData = HandleNOT_EQUAL(theData, fieldName, fieldValue);
}
else
{
theData = HandleEQUAL(theData, fieldName, fieldValue);
}
}
else if (_intProperties.Any(x => x.ToUpper() == fieldName.ToUpper()))
{
iVal = -1;
if (!Int32.TryParse(fieldValue.Trim(), out iVal))
{
throw new Exception($"Malformed Query Parameter: {fieldName} parameter must be an int type");
}
if (_params[0].Contains("NOT(", StringComparison.OrdinalIgnoreCase))
{
theData = theData.Where(x => ((int)x.GetValueByName(fieldName)) != iVal);
}
else
{
theData = theData.Where(x => ((int)x.GetValueByName(fieldName)) == iVal);
}
}
}
#endregion
#region Handles GREATER_THAN() and LESS_THAN()
if(_params[0].Contains("GREATER_THAN(", StringComparison.OrdinalIgnoreCase))
{
fieldName = _params[0].Replace("GREATER_THAN(", "", StringComparison.OrdinalIgnoreCase).Trim();
fieldValue = _params[1].Replace(")", "").Trim();
iVal = -1;
if (!Int32.TryParse(fieldValue.Trim(), out iVal))
{
throw new Exception($"Malformed Query Parameter: {fieldName} parameter must be an int type");
}
if (!_intProperties.Any(x => x.ToUpper() == (fieldName.ToUpper())))
{
throw new Exception($"Malformed Query Parameter: {fieldName} parameter must be an int type");
}
theData = theData.Where(x => ((int)x.GetValueByName(fieldName)) > iVal);
}
if (_params[2].Contains("GREATER_THAN(", StringComparison.OrdinalIgnoreCase))
{
fieldName = _params[2].Replace("GREATER_THAN(", "", StringComparison.OrdinalIgnoreCase).Trim();
fieldValue = _params[3].Replace(")", "").Trim();
iVal = -1;
if (!Int32.TryParse(fieldValue.Trim(), out iVal))
{
throw new Exception($"Malformed Query Parameter: {fieldName} parameter must be an int type");
}
if (!_intProperties.Any(x => x.ToUpper() == (fieldName.ToUpper())))
{
throw new Exception($"Malformed Query Parameter: {fieldName} parameter must be an int type");
}
theData = theData.Where(x => ((int)x.GetValueByName(fieldName)) > iVal);
}
if (_params[0].Contains("LESS_THAN(", StringComparison.OrdinalIgnoreCase))
{
fieldName = _params[0].Replace("LESS_THAN(", "", StringComparison.OrdinalIgnoreCase).Trim();
fieldValue = _params[1].Replace(")", "").Trim();
iVal = -1;
if (!Int32.TryParse(fieldValue.Trim(), out iVal))
{
throw new Exception($"Malformed Query Parameter: {fieldName} parameter must be an int type");
}
if (!_intProperties.Any(x => x.ToUpper() == (fieldName.ToUpper())))
{
throw new Exception($"Malformed Query Parameter: {fieldName} parameter must be an int type");
}
theData = theData.Where(x => ((int)x.GetValueByName(fieldName)) < iVal);
}
if (_params[2].Contains("LESS_THAN(", StringComparison.OrdinalIgnoreCase))
{
fieldName = _params[2].Replace("LESS_THAN(", "", StringComparison.OrdinalIgnoreCase).Trim();
fieldValue = _params[3].Replace(")", "").Trim();
iVal = -1;
if (!Int32.TryParse(fieldValue.Trim(), out iVal))
{
throw new Exception($"Malformed Query Parameter: {fieldName} parameter must be an int type");
}
if (!_intProperties.Any(x => x.ToUpper() == (fieldName.ToUpper())))
{
throw new Exception($"Malformed Query Parameter: {fieldName} parameter must be an int type");
}
theData = theData.Where(x => ((int)x.GetValueByName(fieldName)) < iVal);
}
#endregion
break;
// the most challenging combination because adding onto an IQueryable is an AND, there is no alternative for an OR
case "OR":
// this one is gonna take some brute force
// so I added Dynaimc LINQ via NuGet, which helped tremendously with this OR condition
// seriously debating re-writing this entire thing using the functionality of the dynamic LINQ
// while the code I wrote last night is far from elegant, it's working for the time being. If
// this were indeed a legitimate customer requested project, I would go back through and clean it
// up significantly, but time is of the essence for the turn in on this project.
int iVal1 = -1;
int iVal2 = -1;
ORCondition exprConditions = new ORCondition();
#region Handles EQUAL() and NOT(EQUAL())
if (_params[0].Contains("EQUAL(", StringComparison.OrdinalIgnoreCase))
{
exprConditions.Field1 = _params[0].Replace("EQUAL(", "", StringComparison.OrdinalIgnoreCase).Trim();
exprConditions.Value1 = _params[1].Replace(")", "").Trim();
exprConditions.Operator1 = "==";
if(_stringProperties.Any(x => x.ToUpper() == exprConditions.Field1.ToUpper()) && !exprConditions.Value1.Contains("\""))
{
throw new Exception($"Malformed Query {exprConditions.Field1} parameter must be a string type");
}
if(_intProperties.Any(x => x.ToUpper() == exprConditions.Field1.ToUpper()) && exprConditions.Value1.Contains("\""))
{
throw new Exception($"Malformed Query {exprConditions.Field1} parameter must be an inttype");
}
if (_params[0].Contains("(NOT(", StringComparison.OrdinalIgnoreCase))
{
exprConditions.Not1 = true;
exprConditions.Operator1 = "!=";
}
}
if(_params[2].Contains("EQUAL(", StringComparison.OrdinalIgnoreCase))
{
exprConditions.Field2 = _params[2].Replace("EQUAL(", "", StringComparison.OrdinalIgnoreCase).Trim();
exprConditions.Value2 = _params[3].Replace(")", "").Trim();
exprConditions.Operator2 = "==";
if (_stringProperties.Any(x => x.ToUpper() == exprConditions.Field2.ToUpper()) && !exprConditions.Value2.Contains("\""))
{
throw new Exception($"Malformed Query {exprConditions.Field2} parameter must be a string type");
}
if (_intProperties.Any(x => x.ToUpper() == exprConditions.Field2.ToUpper()) && exprConditions.Value2.Contains("\""))
{
throw new Exception($"Malformed Query {exprConditions.Field2} parameter must be an inttype");
}
if (_params[2].Contains("(NOT(", StringComparison.OrdinalIgnoreCase))
{
exprConditions.Not2 = true;
exprConditions.Operator1 = "!=";
}
}
#endregion
#region Handles GREATER_THAN() and LESS_THAN()
if(_params[0].Contains("GREATER_THAN)", StringComparison.OrdinalIgnoreCase))
{
exprConditions.Field1 = _params[0].Replace("GREATER_THAN(", "", StringComparison.OrdinalIgnoreCase).Trim();
exprConditions.Value1 = _params[1].Replace(")", "").Trim();
exprConditions.Operator1 = ">";
// technically, there shouldn'ty be NOT(GREATER_THAN()) because that would
// pretty much be the same as LESS_THAN()
//if (_params[0].Contains("(NOT(", StringComparison.OrdinalIgnoreCase))
//{
// exprConditions.Not1 = true;
//}
iVal1 = -1;
if (!Int32.TryParse(exprConditions.Value1, out iVal1))
{
throw new Exception($"Malformed Query Parameter: {fieldName} parameter must be an int type");
}
}
if (_params[2].Contains("GREATER_THAN", StringComparison.OrdinalIgnoreCase))
{
exprConditions.Field2 = _params[2].Replace("GREATER_THAN(", "", StringComparison.OrdinalIgnoreCase).Trim();
exprConditions.Value2 = _params[3].Replace(")", "").Trim();
exprConditions.Operator2 = ">";
iVal2 = -1;
if (!Int32.TryParse(exprConditions.Value2, out iVal2))
{
throw new Exception($"Malformed Query Parameter: {fieldName} parameter must be an int type");
}
}
if (_params[0].Contains("LESS_THAN(", StringComparison.OrdinalIgnoreCase))
{
exprConditions.Field1 = _params[0].Replace("LESS_THAN(", "", StringComparison.OrdinalIgnoreCase).Trim();
exprConditions.Value1 = _params[1].Replace(")", "").Trim();
exprConditions.Operator1 = "<";
iVal1 = -1;
if (!Int32.TryParse(exprConditions.Value1, out iVal1))
{
throw new Exception($"Malformed Query Parameter: {fieldName} parameter must be an int type");
}
}
if (_params[2].Contains("LESS_THAN(", StringComparison.OrdinalIgnoreCase))
{
exprConditions.Field2 = _params[2].Replace("LESS_THAN(", "", StringComparison.OrdinalIgnoreCase).Trim();
exprConditions.Value2 = _params[3].Replace(")", "").Trim();
exprConditions.Operator2 = "<";
iVal2 = -1;
if (!Int32.TryParse(exprConditions.Value2, out iVal2))
{
throw new Exception($"Malformed Query Parameter: {fieldName} parameter must be an int type");
}
}
#endregion
exprConditions.Value1 = exprConditions.Value1.Replace("\"", "");
exprConditions.Value2 = exprConditions.Value2.Replace("\"", "");
// we should have everything parsed and the ORCondition populated with the appropriate
// flags to help build the LINQ statement
#region let's try this thing
string dQuery = $"{exprConditions.Field1} {exprConditions.Operator1} ";
if(_stringProperties.Any(x => x.ToUpper() == exprConditions.Field1.ToUpper()))
{
dQuery += $"\"{exprConditions.Value1}\" OR ";
}
else if (_intProperties.Any(x => x.ToUpper() == exprConditions.Field1.ToUpper()))
{
dQuery += $"{exprConditions.Value1} OR ";
}
dQuery += $"{exprConditions.Field2} {exprConditions.Operator2} ";
if(_stringProperties.Any(x => x.ToUpper() == exprConditions.Field2.ToUpper()))
{
dQuery += $"\"{exprConditions.Value2}\"";
}
else if(_intProperties.Any(x => x.ToUpper() == exprConditions.Field2.ToUpper()))
{
dQuery += $"{exprConditions.Value2}";
}
theData = theData.Where(dQuery);
#endregion
break;
default:
throw new Exception($"Malformed Query Parameter: {firstAction} not a recognized function");
}
return theData.ToList();
}