I'm working on a small RestApi that would be able to:
- Interpret different query languages like
(...)/countries?where="..."&select="..." or (...)/GetCountries?ids="1,2,3"
- Translate theses queries to SQL
- Pass the SQL command to dapper and return the result
Note this API will be used by other services and that it won't be exposed to the "public".
I have a first version running but I'm not satisfied with some aspects of my solution...
So how does it work? When the rest api (ASP.NET CORE) receives a query, it will try to find a parser able to interpret it. Currently I have only one simple parser.
public interface IQueryParser
{
Query Parse(QueryContext context);
}
public class DqlQueryParser : IQueryParser
{
private readonly Func<QueryContext, bool> _predicate;
private readonly IQueryOperatorParser _parser;
public DqlQueryParser(Func<QueryContext, bool> predicate, IQueryOperatorParser parser)
{
_predicate = predicate ?? (x => true);
_parser = parser;
}
public Query Parse(QueryContext context)
{
if (context == null)
throw new ArgumentNullException(nameof(context));
if (!string.Equals(context.QueryLanguage, "dql", StringComparison.OrdinalIgnoreCase) || !_predicate(context))
return null;
if (string.IsNullOrEmpty(context.Query))
throw new QueryParserException(context, "The supplied query context does not contain a valid query string. It shouldn't empty or null");
var type = context.Query.GetEntityType();
if (type == null) throw new QueryParserException(context, $"The supplied '{context.Query}' entity is missing or unknown");
switch (context.Method)
{
case "GET":
return new DqlSelectQuery(context.Query, type, _parser?.Parse(context) ?? Enumerable.Empty<QueryOperator>());
case "POST":
return new DqlInsertQuery(context.Query, type, (JObject)JsonConvert.DeserializeObject(context.Body));
case "DELETE":
return new DqlDeleteQuery(context.Query, type, _parser?.Parse(context) ?? Enumerable.Empty<QueryOperator>());
case "PATCH":
case "PUT":
return new DqlUpdateQuery(context.Query, type, (JObject)JsonConvert.DeserializeObject(context.Body));
default:
throw new QueryParserException(context, $"The '{context.Method}' method is not supported");
}
}
}
Each parser can return a sub class of Query. In my case it may be an instance of type DqlSelectQuery, DqlInsertQuery, DqlDeleteQuery, DqlUpdateQuery.
public class DqlDeleteQuery : DqlSelectQuery
{
public DqlDeleteQuery(string raw, Type targetEntityType, IEnumerable<QueryOperator> operators)
: base(raw, targetEntityType, operators) { }
}
public class DqlSelectQuery : Query
{
public List<QueryOperator> Operators { get; }
public DqlSelectQuery(string raw, Type targetEntityType, IEnumerable<QueryOperator> operators)
: base(raw, targetEntityType)
{
Operators = new List<QueryOperator>(operators);
}
}
(...)
In my Select/Delete queries, I'm using the concept of operators. It can be seen as a way to add extra behaviors to a query (select some fields, filter the result, join a table...).
Once the query fully parsed, I identity a translator able to process this query. Indeed, in my current design I may have "lots" of parsers but only very few translators.
public interface IQueryTranslator
{
Type SupportedType { get; }
string Translate(Query query);
}
public class DqlDeleteQueryTranslator : Translator<DqlDeleteQuery>
{
protected override string Translate(DqlDeleteQuery query)
{
var sb = new StringBuilder();
var ops = query.Operators;
sb.AppendLine($"DELETE FROM {query.TargetEntityType.GetSqlEntityName()}");
if (ops.AnyOfType<DqlWhereOperator>())
sb.AppendLine($"WHERE {ops.SingleOfType<DqlWhereOperator>().Condition}");
return sb.ToString().Trim();
}
}
public class DqlSelectQueryTranslator : Translator<DqlSelectQuery>
{
protected override string Translate(DqlSelectQuery query)
{
var ops = query.Operators;
var sb = new StringBuilder();
sb.AppendLine(ops.AnyOfType<DqlSelectOperator>()
? $"SELECT {string.Join(",", ops.SingleOfType<DqlSelectOperator>().Fields)} FROM {query.TargetEntityType.GetSqlEntityName()}"
: $"SELECT * FROM {query.TargetEntityType.GetSqlEntityName()}");
if (ops.AnyOfType<DqlIncludeOperator>())
{
foreach (var dst in ops.OfType<DqlIncludeOperator>())
{
var src = query.TargetEntityType;
var type = src.GetRelationship<RelationshipAttribute>(dst.Type);
if (type == null)
throw new NotSupportedException($"Unknown relationship between {src.Name} and {dst.Type.Name}");
if (type is OneToManyAttribute)
sb.AppendLine($"JOIN {dst.Type.GetSqlEntityName()} {dst.Alias} ON {dst.Name}.{src.GetPrimaryKeys().First()} = {src.Name}.{src.GetPrimaryKeys().First()}");
if (type is ManyToOneAttribute)
sb.AppendLine($"JOIN {dst.Type.GetSqlEntityName()} {dst.Alias} ON {dst.Name}.{dst.Type.GetPrimaryKeys().First()} = {src.Name}.{dst.Type.GetPrimaryKeys().First()}");
if (type is ManyToManyAttribute)
{
string joinName = $"_{src.Name}{dst.Type.Name}";
sb.AppendLine($"JOIN {src.GetRelationship<ManyToManyAttribute>(dst.Type).Join} {joinName} ON {joinName}.{src.GetPrimaryKeys().First()} = {src.Name}.{src.GetPrimaryKeys().First()}");
sb.AppendLine($"JOIN {dst.Type.GetSqlEntityName()} {dst.Alias} ON {dst.Name}.{dst.Type.GetPrimaryKeys().First()} = {joinName}.{dst.Type.GetPrimaryKeys().First()}");
}
}
}
if (ops.AnyOfType<DqlWhereOperator>())
sb.AppendLine($"WHERE {ops.SingleOfType<DqlWhereOperator>().Condition}");
if (ops.AnyOfType<DqlOffsetOperator>())
sb.AppendLine($"OFFSET {ops.SingleOfType<DqlOffsetOperator>().Offset} ROWS");
if (ops.AnyOfType<DqlLimitOperator>())
sb.AppendLine($"FETCH NEXT {ops.SingleOfType<DqlLimitOperator>().Limit} ROWS ONLY");
return sb.ToString().Trim();
}
}
I'm not very happy about these pieces of code. Currently to handle new operators, I need to add more code in this class. It's not exactly scalable or readable. I tried to find some SQL generator but didn't find any!
My guess would be associate a class designed to translate each individual operators. It would be cleaner but then I would need to add many classes with one line of code. Do you have good design patterns/ideas that could me improve my code?
IQeryabale
. If you already plan milions of lines of code then you should probably do it the right way and work with Expression Trees. \$\endgroup\$