A lightweight, secure, and human readable domain-specific query language for searching through databases and in-memory collections. Works alongside REST, GraphQL, and OData to make human readable API queries possible.
$ dotnet add package SearchlightA lightweight, secure query language for searching through databases and in-memory collections using a fluent REST API with robust, secure searching features.
Searchlight is a simple and safe query language for API design. Designed with security in mind, it works well with REST, provides complex features, and is easier to learn than GraphQL or OData.
Both OData and GraphQL are designed to help programmers write queries. Searchlight is designed to help real human beings write queries. Because of its focus on human readability, Searchlight queries can be exposed to end users and they will generally make sense and be readable.
Compare these alternatives:
| Language | Example | Explanation |
|---|---|---|
| Searchlight | name startswith A or status = 'Active' | Friendly and readable |
| OData | (startswith(name, 'A')) or (status eq 'Active') | Functions in OData are designed with programmers in mind, not humans. |
| GraphQL | { { name: "A%" } or: { status: "Active" } } | GraphQL is designed to be written in JSON, not english. |
Many programmers find that OData and GraphQL are required for certain types of integration, but Searchlight can be integrated to make it easy for end users to work with your code. In fact, you can convert a Searchlight query into an OData query using the Linq executor.
in, startsWith, contains, and others. You can create
complex queries using parenthesis and conjunctions (AND/OR).The typical API pattern for Searchlight works as follows:
GET /api/v1/elements?filter=active eq true&include=comments&order=name&pageNumber=2&pageSize=100
This example query does the following things:
elements collectionelements whose active flags are set to truecommentsA more complex Searchlight query might include multiple filter criteria, with more complex conjunctions:
GET /customers/?query=CreatedDate gt '2019-01-01' and (IsApproved = false OR (approvalCode IS NULL AND daysWaiting between 5 and 10))
Searchlight uses type checking, validation, and parsing to convert this query text into an abstract syntax tree (AST) representing search clauses and parameters. You can then convert that AST into various forms and execute it on an SQL database, an in-memory object collection using LINQ, a MongoDB database, or so on. To ensure that no risky text is passed to your database, Searchlight reconstructs a completely new SQL query from string constants defined in your classes, and adds parameters as appropriate. All field names are converted from "customer-visible" field names to "actual database" names. The above query would be transformed to the following:
SELECT *
FROM customers
WHERE created_date >= @p1
AND (approval_flag = @p2 OR (approval_code_str IS NULL AND days_waiting BETWEEN @p3 AND @p4))
Parameters:
- @p1: '2019-01-01'
- @p2: false
- @p3: 5
- @p4: 10
To use searchlight, you construct a "model" that will be exposed via your API. Tag your model with the [SearchlightModel] annotation, and tag each
queryable field with [SearchlightField].
[SearchlightModel]
public class MyAccount
{
// These fields are queryable
[SearchlightField] public string AccountName { get; set; }
[SearchlightField] public DateTime Created { get; set; }
// This field will not be searchable
public string SecretKey { get; set; }
}
When someone queries your API, Searchlight can transform their query into a SQL or LINQ statement:
var engine = new SearchlightEngine().AddAssembly(this.GetType().Assembly);
var list = new List<MyAccount>();
var syntax = engine.Parse(new FetchRequest() {
Table = "MyAccount",
Filter = "AccountName startswith 'alice' and Created gt '2019-01-01'"
});
// To execute via SQL Server
var sql = syntax.ToSqlServerCommand();
var results = conn.Execute(sql.CommandText, sql.Parameters);
// To execute via an in-memory object collection using LINQ
var results = syntax.QueryCollection<EmployeeObj>(list);
Searchlight supports most operators common to SQL, such as:
As well as sort directions specified by ASC and DESC, and encapsulated quotes denoted by '' or "" for filters like Category eq 'Metallica''s Covers'.
Searchlight is designed to mix with other powerful frameworks such as Dapper and AutoMapper to help provide high performance functionality on SQL Server. This example API demonstrates filtering, ordering, pagination, and the ability to return a full row count so the application can display pagination UI elements.
This example demonstrates key techniques:
public async Task<FetchResult<WidgetModel>> QueryWidgets([FromQuery]string filter, [FromQuery]string order, [FromQuery]int? pageSize, [FromQuery]int? pageNumber)
{
var request = new FetchRequest() {filter = filter, order = order, pageNumber = pageNumber, pageSize = pageSize};
var source = DataSource.Create(typeof(WidgetModel), AttributeMode.Strict);
var syntax = source.Parse(request);
var sql = syntax.ToSqlServerCommand(true);
using (var conn = new SqlConnection(_config.GetConnectionString("MyConnectionString")))
{
using (var multi = (await conn.QueryMultipleAsync(sql.CommandText, sql.Parameters, null, null, CommandType.Text)))
{
var totalCount = (await multi.ReadAsync<int>()).ToArray().FirstOrDefault();
var entities = (await multi.ReadAsync<WidgetEntity>()).ToArray();
var models = _mapper.Map<WidgetEntity[], WidgetModel[]>(entities);
return new FetchResult<WidgetModel>(request, models.ToList(), totalCount);
}
}
}
Searchlight allows you to specify optional child collections. By default, child collections are not included in a query; but users can specify other child collections to retrieve along with their primary query. These additional collections are fetched through the multi-recordset mode of Searchlight SQL, so you still have only one database query to retrieve all the information you need.
Using the include parameter, you can fetch WaitList and Copies objects with a single query:
[SearchlightModel]
public class LibraryBook {
[SearchlightField]
public string ISBN { get; set; }
[SearchlightCollection(KeyName = "ISBN")]
public BookReservation[] WaitList { get; set; }
[SearchlightCollection(KeyName = "ISBN")]
public BookCopy[] Copies { get; set; }
}
[SearchlightModel]
public class BookReservation
{
[SearchlightField] public string ISBN { get; set; }
... other fields ...
}
[SearchlightModel]
public class BookCopy
{
[SearchlightField] public string ISBN { get; set; }
... other fields ...
}
Searchlight provides detailed error messages that help you and your customers diagnose problems.
EmptyClause - The user sent a query with an empty open/close parenthesis, like "()".FieldNotFound - The query specified a field whose name could not be found.FieldTypeMismatch - The user tried to compare a string field with an integer, for example.OpenClause - The query had an open parenthesis with no closing parenthesis.InvalidToken - The parser expected a token like "AND" or "OR", but something else was provided.TooManyParameters - The user has sent too many criteria or parameters (some data sources have limits, for example, parameterized TSQL).TrailingConjunction - The query ended with the word "AND" or "OR" but nothing after it.UnterminatedString - A string value parameter is missing its end quotation mark, encapsulated quotes are supported using '' or "".With these errors, your API can give direct and useful feedback to developers as they craft their interfaces. In each case, Searchlight provides useful help:
FieldNotFound error, Searchlight provides the list of all valid field names in the error.InvalidToken error, Searchlight tells you exactly which token was invalid and what it thinks are the correct tokens.Searchlight provides for aliases so that you can maintain backwards compatibility with prior versions. If you decide to rename a field, fix a typo, or migrate from one field to another, Searchlight allows you to tag the field for forwards and backwards compatibility.
[SearchlightModel]
public class MyAccount
{
[SearchlightField(Aliases = new string[] { "OldName", "NewName", "TransitionalName" })]
public string AccountName { get; set; }
}