Getting started
Install
dotnet add package Querio # the model, the builder, the validator
dotnet add package Querio.Sql # and whichever targets you need
Nothing else comes with them - the packages reference only the base class library.
1. Describe what can be queried
A QuerySchema is the vocabulary a query is allowed to use. It is yours to write: Querio does not
read a database, and deliberately so, because the set of things a user may query is rarely the same
as the set of tables that exist.
var schema = new QuerySchema(
entities:
[
new QueryEntity("requests", "Requests",
[
new QueryField("id", "Id", QueryFieldType.Guid),
new QueryField("route", "Route", QueryFieldType.Text),
new QueryField("timestamp", "Timestamp", QueryFieldType.DateTime),
new QueryField("durationMs","Duration, ms", QueryFieldType.Number) { Column = "duration_ms" },
new QueryField("error", "Error", QueryFieldType.Boolean),
new QueryField("apiKeyId", "API key", QueryFieldType.Guid) { Column = "api_key_id" },
])
{ Source = "dbo.RequestLog", PrimaryKey = ["id"] },
new QueryEntity("apiKeys", "API keys",
[
new QueryField("id", "Id", QueryFieldType.Guid),
new QueryField("name", "Name", QueryFieldType.Text),
])
{ Source = "dbo.ApiKeys", PrimaryKey = ["id"] },
],
relations:
[
QueryRelation.Simple("request_apiKey", "requests", "apiKeyId", "apiKeys", "id"),
]);
Three things to notice:
Keyis logical,Source/Columnis physical. The query saysrequestsandapiKeyId; the SQL saysdbo.RequestLogandapi_key_id. Rename the column later and no saved query breaks.Labelis for people. Pickers and generated descriptions use it.QueryFieldTypeis semantic. It decides which operators and aggregates are offered by default, and how a stored value is read back.
You can also narrow what a field allows:
new QueryField("secret", "Secret", QueryFieldType.Text) { Filterable = false, Groupable = false }
2. Build a query
var spec = QueryBuilder.From(schema, "requests", "r")
.Join("apiKeys", "k") // the relation is inferred when only one reaches
.Select("r", "route", "route")
.Select("k", "name", "key")
.CountRows("total")
.Where(f => f
.Since("r", "timestamp", 30, QueryTimeUnit.Day)
.Equal("r", "error", true))
.GroupBy("r", "route")
.GroupBy("k", "name")
.Having(f => f.SelectGreaterThan("total", 100))
.OrderBySelectDescending("total")
.Limit(20)
.Build();
Aliases are mandatory on sources. That is what lets the same entity appear twice - a self-join, or two foreign keys reaching the same target - without the two occurrences becoming ambiguous.
3. Check it
var result = spec.Validate(schema);
if (!result.IsValid)
{
foreach (var error in result.Errors)
Console.WriteLine($"{error.Path}: {error.Message}");
}
Validation is about coherence against the schema, not about any particular backend. Whether a given target can express the query is a separate question, answered when you render it.
4. Render it
var sql = SqlRenderer.Render(spec, schema, SqlServerDialect.Instance);
Console.WriteLine(sql.Sql);
foreach (var p in sql.Parameters)
Console.WriteLine($"{p.Name} = {p.Value}");
SELECT TOP (20) [r].[route] AS [route], [k].[name] AS [key], COUNT(*) AS [total]
FROM [dbo].[RequestLog] AS [r]
INNER JOIN [dbo].[ApiKeys] AS [k] ON [r].[api_key_id] = [k].[id]
WHERE [r].[timestamp] >= DATEADD(day, -30, SYSUTCDATETIME()) AND [r].[error] = @p0
GROUP BY [r].[route], [k].[name]
HAVING COUNT(*) > @p1
ORDER BY [total] DESC
@p0 = True
@p1 = 100
Hand the text and the parameters to whatever you already use - Dapper, ADO.NET, anything. Querio opens no connections.
5. Save it and read it back
The query is a plain object, so any serializer will do:
var json = JsonSerializer.Serialize(spec, JsonOptions);
var back = JsonSerializer.Deserialize<QuerySpec>(json, JsonOptions)!;
Or keep it somewhere a person can read:
var url = QueryHttp.ToUri(spec, schema, "/reports"); // survives a bookmark
var said = QueryDescriber.Describe(spec, schema); // "From Requests (r), showing Route ..."
Both read back into the same query.
When a target cannot do it
try
{
SqlRenderer.Render(spec, schema, SqliteDialect.Instance);
}
catch (QueryRenderException error)
{
// error.Feature == QueryFeature.Percentile
Console.WriteLine(error.Message);
}
This is intentional. A target that approximated instead would answer a different question and never tell you.
To know in advance - to grey out an option in a UI, say - ask before building:
var choices = QueryChoices.For(spec, schema, SqliteDialect.Instance);
choices.AggregatesFor(new QueryFieldRef("r", "durationMs")); // Percentile is not in the list
Next
- Architecture - why the model looks like this
- Targets - what each one can and cannot express
- The query language - writing a query as text instead of code