CL.SQLite — Query Builder
Typed LINQ-shaped expressions translated to SQL — filters, multi-key ordering, projections,
GroupByaggregates, paging, bulk update & delete, and raw SQL.
See the overview for loading, the repository, schema sync, configuration, and events.
db.GetQueryBuilder<T>() returns a QueryBuilder<T> you compose fluently. Nothing executes until a terminal method runs; each terminal returns a Result<…>. The builder translates expressions to a single parameterized SQL statement, runs it against the embedded SQLite engine, and materializes rows into T.
var db = Libraries.Get<SQLiteLibrary>();
Result<List<Order>> orders = await db.GetQueryBuilder<Order>()
.Where(o => o.Status == "open" && o.Total > 100)
.OrderByDescending(o => o.CreatedUtc)
.Take(50)
.ToListAsync();
if (orders.IsSuccess)
foreach (var o in orders.Value!) { /* … */ }
Each entry point accepts a connectionId selecting a named database from config.sqlite.json; it defaults to "Default". db.GetQueryBuilder<Order>("Reporting") queries the Reporting database.
Filtering with Where
Where takes an Expression<Func<T, bool>> and translates it to a parameterized WHERE clause. Chained calls are AND-combined.
db.GetQueryBuilder<Order>()
.Where(o => o.Status == "open")
.Where(o => o.Total >= 100 && o.Total < 1000)
.Where(o => o.CreatedUtc >= DateTime.UtcNow.AddDays(-30));
Supported expression shapes:
| Shape | Translates to |
|---|---|
== != < <= > >= |
the matching SQL operator |
&& \|\| ! |
AND / OR / NOT (…) |
x.Flag on a bool member |
"Flag" = @p bound to true |
x.Name.Contains/StartsWith/EndsWith(s) |
LIKE '%s%' / 's%' / '%s' |
== null / != null |
IS NULL / IS NOT NULL |
collection.Contains(x.Id) — array, List<T>, HashSet<T>, any IEnumerable |
"Id" IN (@p0, @p1, …), one bound parameter per element |
Captured local variables and expressions such as DateTime.UtcNow.AddDays(-30) are evaluated at build time and bound as parameters — values never land in the SQL string. An IN over an empty collection emits the literal 1=0, matching nothing, rather than invalid SQL.
Anything else — another method call, arithmetic on a column, or comparing two columns to each other — raises NotSupportedException. Note where it surfaces: QueryBuilder.Where translates the expression eagerly, so an unsupported predicate throws out of the Where call itself rather than arriving as a failed Result from the terminal. (Repository.FindAsync translates inside its own try, so there the same predicate comes back as a failed Result.)
Ordering
Order by one key with OrderBy / OrderByDescending, then add tie-breakers with ThenBy / ThenByDescending. Each takes a key selector.
db.GetQueryBuilder<Order>()
.OrderBy(o => o.Status)
.ThenByDescending(o => o.CreatedUtc)
.ThenBy(o => o.Id);
Paging
Limit / Take and Offset / Skip are aliases for SQL LIMIT and OFFSET. An offset with no limit is legal — SQLite's grammar is LIMIT expr [OFFSET expr], so it is emitted as LIMIT -1 OFFSET n, meaning "skip n, then everything":
db.GetQueryBuilder<Order>()
.OrderByDescending(o => o.CreatedUtc)
.Skip(40) // alias: Offset(40)
.Take(20); // alias: Limit(20)
For first-page metadata, use the paged terminal:
Result<PagedResult<Order>> page = await db.GetQueryBuilder<Order>()
.Where(o => o.Status == "open")
.OrderByDescending(o => o.CreatedUtc)
.ToPagedListAsync(page: 1, pageSize: 25);
if (page.IsSuccess)
{
PagedResult<Order> p = page.Value!;
// p.Items, p.PageNumber, p.PageSize, p.TotalItems, p.TotalPages
}
Projections — Select
Select takes an Expression<Func<T, object?>> and restricts the emitted column list to just the members the projection touches, instead of the default SELECT *.
db.GetQueryBuilder<Order>()
.Select(o => o.Total); // SELECT "total"
Rows are still materialized into T; columns you did not select are simply left at their default values on the returned instances.
Anonymous-type projections work the same way: every member — single or anonymous — is resolved back to the source entity's mapped ColumnName, and each emitted name is double-quoted, so renamed columns and reserved words are both safe.
db.GetQueryBuilder<Order>()
.Select(o => new { o.Id, o.CreatedUtc }); // SELECT "id", "created_utc"
Aggregates — GroupBy
GroupBy<TKey> adds a GROUP BY clause to the row-returning terminals — ToListAsync and ToPagedListAsync.
db.GetQueryBuilder<Order>()
.Where(o => o.CreatedUtc >= DateTime.UtcNow.AddDays(-7))
.GroupBy(o => o.Day);
GroupBy also reaches the two counting terminals, where it changes what is being counted:
CountAsyncon a grouped builder returns the number of groups, not the number of rows — the grouped query is wrapped inSELECT COUNT(*) FROM (…).ToPagedListAsyncreports the same number asTotalItems, so paging through grouped rows pages correctly.
The typed aggregates refuse a grouped builder.
SumAsync,MinAsyncandMaxAsyncthrowNotSupportedExceptionwhen the builder carries aGroupBy, because a per-group aggregate has no single scalar answer and silently dropping the grouping would answer a different question. Read the grouped rows withToListAsync, or compute per-group aggregates withRawQueryAsync.
The aggregate terminals evaluate in the database over the current WHERE:
Result<decimal> revenue = await db.GetQueryBuilder<Order>()
.Where(o => o.Day == today)
.SumAsync(o => o.Total);
Result<decimal> top = await db.GetQueryBuilder<Order>().MaxAsync(o => o.Total);
Result<decimal> low = await db.GetQueryBuilder<Order>().MinAsync(o => o.Total);
Terminal operations
Nothing runs until a terminal is called; each returns a Result<…>. There is no AverageAsync, AnyAsync or SingleAsync on this builder — SUM, MAX and MIN are the only aggregate terminals.
| Terminal | Returns | SQL |
|---|---|---|
ToListAsync(ct) |
Result<List<T>> |
SELECT … |
FirstOrDefaultAsync(ct) |
Result<T?> |
SELECT … LIMIT 1; the limit is local to the call, so the builder can be reused |
ToPagedListAsync(page, pageSize, ct) |
Result<PagedResult<T>> |
data page + COUNT(*); fails validation if page or pageSize < 1 |
CountAsync(ct) |
Result<long> |
SELECT COUNT(*); with GroupBy, counts groups |
SumAsync<TResult>(selector, ct) |
Result<TResult> |
SELECT SUM(col); throws NotSupportedException with GroupBy |
MaxAsync<TResult>(selector, ct) |
Result<TResult> |
SELECT MAX(col); throws NotSupportedException with GroupBy |
MinAsync<TResult>(selector, ct) |
Result<TResult> |
SELECT MIN(col); throws NotSupportedException with GroupBy |
DeleteAsync(ct) |
Result<int> |
DELETE … WHERE … |
UpdateAsync(updates, ct) |
Result<int> |
UPDATE … SET … WHERE … |
Result<long> open = await db.GetQueryBuilder<Order>().Where(o => o.Status == "open").CountAsync();
Result<Order?> first = await db.GetQueryBuilder<Order>().Where(o => o.Id == 1).FirstOrDefaultAsync();
Bulk update & delete
DeleteAsync and UpdateAsync issue a single set-based statement without materializing rows. Both return the number of affected rows. UpdateAsync takes an explicit column map whose keys are column names as they exist in the table (not C# property names); they are quoted and emitted into the SET list, with values bound as positional parameters (@p0, @p1, …) so that a column name which is not a legal parameter token still works. The names themselves are still emitted into the statement, so pass literal column names you control, never user input.
// Bulk delete by predicate
Result<int> purged = await db.GetQueryBuilder<Order>()
.Where(o => o.CreatedUtc < DateTime.UtcNow.AddYears(-3))
.DeleteAsync();
// Bulk update by predicate, via an explicit column map
Result<int> renamed = await db.GetQueryBuilder<Note>()
.Where(n => n.Title == "")
.UpdateAsync(new Dictionary<string, object?> { ["title"] = "(untitled)" });
A
DeleteAsyncorUpdateAsyncwith noWhereaffects every row in the table. Always scope bulk mutations with a predicate unless that is genuinely intended.
Raw SQL escape hatches
When the builder can't express something, drop to parameterized raw SQL on the repository. Both take named parameters as a Dictionary<string, object?>.
var repo = db.GetRepository<UserRecord>();
// Materialize rows into T with the same mapper as the builder
Result<List<UserRecord>> rows = await repo.RawQueryAsync(
"SELECT * FROM users WHERE country = @c AND created_utc >= @since",
new() { ["@c"] = "DK", ["@since"] = since });
// Non-query — returns affected rows
Result<int> n = await repo.RawExecuteAsync(
"UPDATE users SET active = 0 WHERE last_seen < @cutoff",
new() { ["@cutoff"] = cutoff });
Always bind values via named parameters — never interpolate user input into the SQL string.
See also
- Overview — loading, the repository, schema sync, configuration, and events.
- Getting Started — load, configure, and use any
CL.*library. - API Reference — generated type/member documentation.
- Package on NuGet