Giter VIP home page Giter VIP logo

csharp-searchlight's Introduction

NuGet GitHub Workflow Status SonarCloud Coverage SonarCloud Bugs SonarCloud Vulnerabilities

Searchlight

A lightweight, secure query language for searching through databases and in-memory collections using a fluent REST API with robust, secure searching features.

What is Searchlight?

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.

Key features of Searchlight

  • Fast Searchlight uses precalculated search tables for performance of roughly 24 microseconds per six calls to Parse, or about 4 microseconds per FetchRequest object parsed.
  • Safe from SQL injection As a compiled language, the Searchlight query language is safe from SQL injection attacks. Malformed queries generate clear error messages within Searchlight, and if you choose to use Searchlight on top of an SQL database, all queries executed on your database will use parameterized values.
  • Human readable Unlike JSON-based query systems, Searchlight is easily readable and should be familiar to most people who are comfortable using SQL and LINQ languages. Searchlight uses words instead of symbols to avoid unnecessary escaping rules for HTML and HTTP requests.
  • Database independent You can use Searchlight against SQL databases, NoSQL databases, or in-memory collections. If you change your mind later and decide to switch to a different database technology, Searchlight still works.
  • Powerful queries Searchlight lets you execute complex search statements such as in, startsWith, contains, and others. You can create complex queries using parenthesis and conjunctions (AND/OR).
  • Self-documenting If you mistype the name of a field, you get an error that indicates exactly which field name was misspelled, and a list of all known fields you can use.
  • Programmatic control You can examine the Searchlight abstract syntax tree for performance problems, inappropriate filters, or query statements too complex for your database and reject those queries before they waste unnecessary query cycles on your data store.

Using Searchlight

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:

  • Fetch data from the elements collection
  • Only fetch elements whose active flags are set to true
  • Include the extra data element known as comments
  • Paginate the results into pages of size 100, and fetch page number two

A 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

How does Searchlight work?

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);

Constructing a Query

Searchlight supports most operators common to SQL, such as:

  • Equals (=, EQ)
  • Greater Than (>, GT)
  • Greater Than Or Equal (>=, GE)
  • Less Than (<, LT)
  • Less Than Or Equal (<=, LE)
  • Not Equal (!=, NE, <>)
  • In
  • Contains
  • StartsWith
  • EndsWith
  • IsNull
  • AND
  • OR

As well as sort directions specified by ASC and DESC, and encapsulated quotes denoted by '' or "" for filters like Category eq 'Metallica''s Covers'.

Database independence with Searchlight, Dapper, and AutoMapper

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:

  • Widgets are known inside the database by one class, "WidgetEntity", yet are expressed through the API as a different class, "WidgetModel". This allows you to rename fields, rename tables, enforce transformation logic, and make certain fields visible either internally or externally.
  • Pagination uses the "Page Size" and "Page Number" pattern. You could implement similar features using Skip and Take if preferred.
  • The exact SQL Server query uses a temporary table and multiple result sets to ensure that only the exact rows specified are returned to the caller. The SQL command retrieves the minimum amount of data possible, plus it also tells you the total count of records so your user interface can show the exact number of pages.
  • This pattern uses Dapper Contrib to fetch widget entities using asynchronous queries.
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);
        }
    }
}

Fetching child collections with Searchlight

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 ...
}

What if a developer makes a mistake when querying?

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:

  • When the user gets a FieldNotFound error, Searchlight provides the list of all valid field names in the error.
  • If you see an InvalidToken error, Searchlight tells you exactly which token was invalid and what it thinks are the correct tokens.

What if my data model changes over time?

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; }
}

csharp-searchlight's People

Contributors

alexrussak avatar alexrussak-sage avatar charliettaylor avatar ctylerd avatar dakotajunkman avatar dependabot[bot] avatar kimsternator avatar mullan avatar tspence avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar

csharp-searchlight's Issues

Add a Validate function that inspects a fetch request

Right now Searchlight uses exceptions to indicate that parsing failed. It might be better instead to produce a list of validation results, so that if more than one error could be determined at a time we could use that rather than the exception system.

Today, the code looks like this:

try {
    var request = new FetchRequest() { ... };
    var syntax = engine.Parse(request);
    var sql = syntax.ToSqlServerCommand(true);
} catch (e) { ... }

However, some programmers may not want to use exceptions. Why don't we also support a Validate() or TryParse() method? Then, the code could look like this:

var request = new FetchRequest() { ... };
if (!engine.Validate(request, out errors)) {
  Console.WriteLine(errors.ToString());
}
if (engine.TryParse(request, out syntax)) { 
  // Execute the syntax
}

Optional 1-1 mappings

Searchlight supports child collections, but it doesn't yet support 1-1 mappings (e.g. Parent Company as an optional include fetch for Company).

This doesn't work because the SearchlightCollections attribute doesn't work unless the object is an array.

We should have the ability to support optional 1-1 join fetches.

Implement defined date operators

For simplicity of queries, support shorthand for dates such as simple english words like TODAY.

Implement date-relative queries so that users have a simple shorthand for defining queries that refer to known points in the past, such as the following:

  • Last six months
  • Year to date
  • This week

Support "Maximum Page Size" across the SearchlightEngine

The object SearchlightEngine should allow you to define MaximumPageSize across all objects.

  • Any FetchRequest that does not specify a page size should have a page size set to MaximumPageSize
  • Any FetchRequest where the PageSize value is > MaximumPageSize should throw an error

SearchlightFlag attribute should not require an empty Aliases array

If you specify [SearchlightFlag(Name = "name")]

The application crashes with a null reference exception.

Workaround is to specify
[SearchlightFlag(Name = "name", Aliases = new string[] { })]

Also if the flag name is required it may be useful to have a constructor on the attribute so we can use
[SearchlightFlag("name")]

Support "Skip" and "Take" for alternative pagination

For some people, specifying PageSize and PageNumber may be friendly and useful. That way they can simply add one or subtract one from the page number and move from page to page.

For other people, specifying Skip and Take may be more convenient. They might want to write long running processes to download records gradually, and they might not want to calculate page size and page numbers. Let's make it possible for Searchlight to support both techniques, and that way API designers can allow either PageSize/PageNumber or Skip/Take (of course, not at the same time!)

Implement filtering and pagination for child tables

When fetching records, it would be nice to support filtering for child tables and entities.

For example, if I'm fetching news articles, it would be helpful to be able to fetch comments at the same time. However, a news article can have thousands of comments, so we need some way to filter and paginate them.

Ideally, we should support full Searchlight syntax for this. So the end result should be something like the following:

{
  "table": "articles",
  "filter": "date gt 2021-01-01 and headline eq true",
  "include": "top 10 comments where isDeleted eq false and qualityRating > 4.0,links where isActive eq true"
}

Add human-friendly "Message" to all searchlight exceptions

All Searchlight exceptions should implement a message that contains a human-friendly explanation of what went wrong and what the user needs to do to fix their query.

In the most recent changeset, we added XMLDOC text explaining what each error is. Let's build upon that and add human friendly messages.

Add clarity around AND and OR conjunctions

It is possible to write a query using the following syntax:

a = b AND c = d OR e = f

In this construction, it's not clear what order of operations should be followed and the end behavior is undefined and implementation specific. We should consider this an error - all expressions within a single CompoundClause should be required to have identical conjunctions. If they do not, we should throw an exception.

Support date math in Searchlight queries

We intend to use Searchlight to define webhook subscriptions. These criteria should permit date math, so you could for example subscribe to notifications for "objects due in the next 30 days" or similar.

Searchlight & Swashbuckle extension

It would be nice to define a Searchlight extension for Swashbuckle that could automatically fill out help text in Swagger for fields like Include, Filter, and Pagination.

SearchlightField EnumType doesn't do anything

I was attempting to set up an enum to query on and it doesn't seem like setting the EnumType does any conversion.

Example

public enum CarType
{
    Sedan = 0,
    SUV = 1,
    // etc.
}

[SearchlightModel(OriginalName = "Car", DefaultSort = nameof(Type))]
public class Car
{
    [SearchlightField(FieldType = typeof(int), EnumType = typeof(CarType))]
    public CarType Type { get; set; }
}

If I attempt a filter like type eq Sedan, the filter fails with Searchlight.FieldTypeMismatch. There should be an automatic conversion from the enum name to the underlying value so that using type eq 0 and type eq Sedan are both valid filters.

Add a Default Sort to the model when one isn't specified.

When a default sort value is not specified in the SearchlightModel attribute, Searchlight creates invalid SQL.

SELECT COUNT(1) AS TotalRecords FROM {table} WHERE {property} = @p1;
SELECT * FROM {table} WHERE {property} = @p1 OFFSET 20 ROWS FETCH NEXT 20 ROWS ONLY;

The second SQL statement is missing an order by.

Add clarity around case sensitivity

Reasonable people can expect different approaches for case sensitivity. Searchlight should allow you to specify, at the engine level, whether the engine is case sensitive or case insensitive.

We should then throw an exception if the executor does not support the case sensitivity regimen specified at the engine level.

Implement database executor tests

We have assertions in our code that the SQL executor produces syntactically valid SQL logic. However, we should really use a toolkit like Postgres2Go and SQLite to add tests for execution against different databases.

Scanning an assembly doesn't work if it has child namespaces

Seems like the code to construct a searchlight engine doesn't scan the assembly properly if it has child namespaces.

Example:

  • A library with the namespace BusinessLayer with class ParentClass
  • A sub-namespace called BusinessLayer.Models with models in it
  • Construct the searchlight engine using the assembly from ParentClass:
            var engine = new SearchlightEngine().AddAssembly(typeof(ParentClass).Assembly);
  • In this case, it does not detect the models inside BusinessLayer.Models.

Add support for flags

In some cases, people may want to use the "$include" parameter to specify options that are not part of searchlight.

Let's add support for a "SearchlightFlag" system that allows users to extend the usage of the "$include" parameter.

Add support for sorting to LinqExecutor.QueryCollection

The QueryCollection method does not make use of the order by clauses in the FetchRequest.

So we should add support for sorting in an in-memory collection.

This can be accomplished by using the LINQ methods
OrderBy: https://docs.microsoft.com/en-us/dotnet/api/system.linq.queryable.orderby?view=net-5.0
OrderByDescending: https://docs.microsoft.com/en-us/dotnet/api/system.linq.queryable.orderbydescending?view=net-5.0

and for additional columns
ThenBy: https://docs.microsoft.com/en-us/dotnet/api/system.linq.queryable.thenby?view=net-5.0
ThenByDescending: https://docs.microsoft.com/en-us/dotnet/api/system.linq.queryable.thenbydescending?view=net-5.0

Null pointer exception on "IN" query for zero items

The following query should throw a searchlight "bad input" exception, but it does not:

Filter=(Field IN ()) AND OtherField eq 'test'

We should detect when an IN clause has zero items and report an input error.

Only get the first result

It looks like the Searchlight engine doesn't allow a page size of one.
If I write a Searchlight query and only want the first result back, what's the best way to do that?

if (query.PageSize <= 1)
{
throw new InvalidPageSize { PageSize = request.pageSize == null ? "not specified" : request.pageSize.ToString() };
}

Implement other comparisons for Strings

We saw this error during testing:

ProblemId: System.InvalidOperationException at Searchlight.LinqExecutor.BuildOneExpression
OuterMessage: The binary operator LessThanOrEqual is not defined for the types 'System.String' and 'System.String'.

I suspect this means that we need to modify LTE and GTE for strings.

Add Support for NOT IN

If I submit a filter like CompanyId NOT IN ('9cdc47ce-a02d-4f7a-8d72-e5e9732a3b06')

It gets converted to the SQL

CompanyId IN (@p1)

producing the opposite of the intended results

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.