SQLite provider
Use
nextorm.sqlitefor file-based or in-memory SQLite databases; it renders$nameparameters,limit/offsetpaging,ifnullcoalescing andstrftimedate parts, and registers customstdev/varaggregates.
Prerequisites: Provider overview · Quickstart
Overview
SqliteDataContext (src/nextorm.sqlite/SqliteDataContext.cs) wraps Microsoft.Data.Sqlite. It creates a
SqliteConnection from the connection string, registers the custom aggregate functions on every
connection it creates, and returns Instance from its Dialect property.
SqliteDialect (src/nextorm.sqlite/SqliteDialect.cs) is the dialect:
- parameter placeholder
$name; - string concatenation with
||; MakeCoalescerendersifnull(a, b);MakeNowrendersdatetime('now')for both local and UTC (SQLite has no now());- date parts use
cast(strftime(...) as integer)foryear/month/day/hour/minute/secondanddayofyear(%j), falling back to ANSIextract(part from value)for anything else; Math.Logmaps toln(...)(SQLite'slog()is base 10);- paging is
limit n/limit n offset m; offset without limit becomeslimit -1 offset m.
Registering the provider
Two overloads are available on DataContextBuilder
(src/nextorm.sqlite/DI/SqliteDataContextOptionsBuilderExtensions.cs):
using NextORM.Core;
using NextORM.Sqlite;
// From a file path (the debug build checks that the file exists).
var byPath = new DataContextBuilder().UseSqlite("app.db");
// From an existing, caller-owned connection.
using var connection = new Microsoft.Data.Sqlite.SqliteConnection("Data Source=:memory:");
var byConnection = new DataContextBuilder().UseSqlite(connection);
using var ctx = byPath.CreateDataContext(); // IDataContext
You can also construct the context directly (this is what the provider tests do):
using NextORM.Core;
using NextORM.Sqlite;
using IDataContext ctx = new SqliteDataContext("Data Source=app.db", new DataContextBuilder());
Custom aggregate functions
Microsoft.Data.Sqlite has no attribute-based auto-registration, so the core library registers its custom
aggregates explicitly and once per connection
(src/nextorm.sqlite/SQLiteFunctions.cs, called from OnConnectionCreated):
| Function | Meaning |
|---|---|
stdev |
sample standard deviation (n−1) |
stdevp |
population standard deviation (n) |
var |
sample variance (n−1) |
varp |
population variance (n) |
The four share one VarianceAccumulator; a flavour returns null when there are too few non-null rows
(fewer than 2 for the sample flavour, fewer than 1 for the population flavour). Because the functions are
already named stdev/var, SQLite performs no aggregate name remapping.
That null promise survives the CLR boundary: the four entity terminals and the Sql.stdev*/Sql.var*
facade forms return T?, so a null from the accumulator reaches the caller as null instead of
throwing on a non-nullable double. A one-row population result is still 0.0, and duplicates
collapsed by DISTINCT follow the same cardinality rule as the plain forms.
var stddev = ctx.From<IComplexEntity>()
.Select(x => SqlFunctions.Sql.stdev((double)x.Id))
.First();
select stdev(cast(id as double precision))
from complex_entity
Date parts, coalesce and LIKE
var query = ctx.From<IComplexEntity>()
.Select(x => new
{
Year = x.Datetime!.Value.Year,
Fallback = x.String ?? "",
});
select cast(strftime('%Y', dt) as integer) as 'Year', ifnull(somestring, '') as 'Fallback'
from complex_entity
SqlFunctions.Sql.like(column, pattern) and SqlFunctions.Sql.like(column, pattern, escapeChar) render a like
predicate; the string methods (Contains, StartsWith, EndsWith) are translated to like with the
appropriate wildcards.
Paging
ctx.From<IComplexEntity>().Page(5, 10).Select(x => x.Id); // limit 5 offset 10
ctx.From<IComplexEntity>().Offset(10).Select(x => x.Id); // limit -1 offset 10
select id
from complex_entity limit 5 offset 10
select id
from complex_entity limit -1 offset 10
SQLite has no OFFSET without LIMIT, so an offset-only query emits the sentinel limit -1.
SQLite-only functions
SqlFunctions.Sqlite exposes the SQLite-only surface: the core scalars, JSON1, the aggregate functions,
the date helpers, the math-extension functions and the FTS3/FTS4/FTS5 full-text query surface.
json_each/json_tree and the FTS5 table-valued search are available as table functions. Other
providers reject every member of the surface with a NotSupportedException.
ctx.From<IComplexEntity>()
.Select(x => new
{
Json = SqlFunctions.Sqlite.json_extract<string>(x.String, "$.name"),
Kind = SqlFunctions.Sqlite.@typeof(x.String),
Total = SqlFunctions.Sqlite.total(x.NullableInt),
Pi = SqlFunctions.Sql.pi()
});
The shipped aggregate is total(value): it returns double and never null, so an empty or all-null
group yields 0.0.
Full-text search (FTS3/FTS4/FTS5)
The FTS members query an FTS virtual table, and the SQLite-only maintenance command surface (the
IDataContext.CreateSqliteFts5CommandBuilder(tableName) factory, returning SqliteFts5CommandBuilder)
tunes it; nextorm never creates the table. Query support exists only when the SQLite build includes
the module (the bundled provider ships fts3, fts4 and fts5). The scalar/table-valued FTS query
API lives on SqlFunctions.Sqlite; the maintenance commands are a separate command-builder surface,
not members of SqlFunctions.Sqlite. Both are separate from the cross-provider
SqlFunctions.Sql.contains/freetext predicates: those stay gated by
SupportsFullText, which is false on SQLite, so
they throw NotSupportedException. Every FTS member is SQL-only — the in-memory provider throws
NotSupportedException.
| C# | SQLite | Module |
|---|---|---|
Match(tableOrColumn, query) |
tableOrColumn MATCH query |
FTS3/4/5 |
MatchTable<TEntity>(table, query) |
table(query) as a FROM source |
FTS5 |
FTS5bm25(table) / FTS5bm25(table, weights…) |
bm25(table[, weights…]) |
FTS5 |
Highlight(table, columnIndex, startMatch, endMatch) |
highlight(...) |
FTS5 |
Snippet(table, columnIndex, startMatch, endMatch, ellipses, tokens) |
snippet(...) |
FTS5 |
Rank(table) |
table.rank (hidden column) |
FTS5 |
Rank(matchInfo) |
rank(matchInfo) (needs the rank UDF) |
FTS3/4 |
RowId(table) |
table.rowid (hidden column) |
FTS3/4 |
FTS3Offsets(table) |
offsets(table) |
FTS3/4 |
FTS3MatchInfo(table[, format]) |
matchinfo(table[, format]) |
FTS3/4 |
FTS3Snippet(table, …) |
snippet(table, …) |
FTS3/4 |
The first argument of the auxiliary functions (and the table token of Match) is a trusted constant
table name; it is emitted as a quoted identifier, so never build it from user input. The FTS query is
bound as a parameter when it is a run-time value and inlined as a literal when it is constant.
var query = "hello";
var rows = ctx.From<Article>()
.Where(x => SqlFunctions.Sqlite.Match("article_fts", query))
.OrderBy(x => x.RowId)
.Select(x => new
{
x.RowId,
x.Title,
Score = SqlFunctions.Sqlite.FTS5bm25("article_fts"),
Preview = SqlFunctions.Sqlite.Snippet("article_fts", 1, "[", "]", "...", 8)
})
.ToList();
select rowid, title, bm25("article_fts") as 'Score',
snippet("article_fts", 1, '[', ']', '...', 8) as 'Preview'
from article_fts
where "article_fts" match $query
order by rowid
FTS5 also has a table-valued FROM form, MatchTable, which composes like any other source
(From(() => ...)):
var rows = ctx
.From(() => SqlFunctions.Sqlite.MatchTable<Article>("article_fts", query))
.OrderBy(x => x.RowId)
.Select(x => new { x.RowId, x.Title })
.ToList();
select rowid, title
from "article_fts"($query) order by rowid
FTS3/4 has no built-in rank: SQLite resolves rank(matchinfo(...)) through a connection-registered
SQL UDF named rank, which the provider does not register. Register it on every connection or the
query fails at execution with SQLite Error 1: no such function: rank:
using Microsoft.Data.Sqlite;
using var connection = new SqliteConnection("Data Source=app.db");
connection.CreateFunction<byte[]?, long>("rank", static matchInfo =>
{
// Score the matchinfo() blob (see SQLite's matchinfo documentation); this sample just sums bytes.
long score = 0;
if (matchInfo is not null)
foreach (var b in matchInfo)
score += b;
return score;
});
var scores = ctx.From<Article>()
.Where(x => SqlFunctions.Sqlite.Match("article_fts", "hello"))
.Select(x => new
{
x.RowId,
Score = SqlFunctions.Sqlite.Rank(SqlFunctions.Sqlite.FTS3MatchInfo("article_fts"))
})
.ToList();
Creating and populating the index stays outside the query surface (create the virtual table with raw
SQL), but the FTS5 maintenance/control commands are available through the SQLite-only command builder
IDataContext.CreateSqliteFts5CommandBuilder(string tableName):
| Operation | Rendered values | Notes |
|---|---|---|
AutoMerge(value) |
('automerge', value) |
value is 0..16; other values throw |
CrisisMerge(value) |
('crisismerge', value) |
value must be nonnegative; 0/1 pass through unchanged |
Merge(pages) |
('merge', pages) |
any signed int, passed through unchanged |
Optimize() |
('optimize') |
one-column form |
Rebuild() |
('rebuild') |
one-column form; unavailable for contentless FTS5 tables |
IntegrityCheck(checkExternalContent = null) |
('integrity-check') / ('integrity-check', 0\|1) |
one-column when the flag is omitted; with true also verifies external content |
Each operation returns a new immutable builder; the terminals are ToSql() (renders without touching
the database), Execute() (int affected rows) and ExecuteAsync(CancellationToken = default)
(Task<int>). Calling a terminal before selecting an operation throws InvalidOperationException.
The two-column "rank" form is used by automerge/crisismerge/merge, the one-column form by
optimize/rebuild (and by integrity-check with the flag omitted). These are command-surface
statements — not scalar or table-valued functions and not part of SqlFunctions.Sqlite. The table name
is rejected if it is null, empty, whitespace or contains a NUL character.
var builder = ctx.CreateSqliteFts5CommandBuilder("article_fts");
var sql = builder.AutoMerge(4).ToSql();
// INSERT INTO "article_fts" ("article_fts", "rank") VALUES ('automerge', 4)
var affected = builder.Optimize().Execute();
The builder is SQLite-only: on every other provider the surface throws
NotSupportedException($"{dialect.GetType().Name} does not support SQLite FTS5 maintenance commands.")
before any database access. On success, Execute/ExecuteAsync return the driver's affected-row
int, nonnegative for a successful maintenance command; nextorm promises no fixed count or value
(these are not page or repair counts). Native SQLite errors propagate. Rebuild is unavailable for
contentless FTS5 tables, and IntegrityCheck(true) also verifies external content.
See SQLite-specific SQL for the full list, the native SQLite spelling and the version/build-option requirements.
Limitations
SQLite has no ANY/ALL subquery support. SqlFunctions.Sql.any(...) / SqlFunctions.Sql.all(...) are translated to
SQL, and the database rejects them at execution time with a SqliteException — the failure is not raised
during translation.
// Throws Microsoft.Data.Sqlite.SqliteException when executed.
await ctx.From<ISimpleEntity>()
.Where(it => it.Id == SqlFunctions.Sql.any(ctx.From<IComplexEntity>().Select(c => c.Id)))
.Select(it => it.Id)
.ToListAsync();
IntersectAll and ExceptAll are rejected by the dialect with a NotSupportedException because SQLite
has no intersect all / except all.
The LOB streaming terminals (ToStream/ToTextReader) are supported for a single byte[]/string column (binary and text). SQLite has no row locator of its own, so Microsoft.Data.Sqlite returns a true streaming SqliteBlob only when the query also selects rowid; the dialect appends its trailing LobLocatorColumn (rowid, with the payload staying at ordinal 0). The source must therefore be a normal rowid table: on a view or a WITHOUT ROWID table the single-column streaming command fails closed with the raw Microsoft.Data.Sqlite.SqliteException: no such column: rowid — there is no buffered fallback on that scalar/locator LOB path (the multi-column ToDataReader below uses a separate, buffered, locator-free seam). See Streaming large objects.
The multi-column ToDataReader/ToDataReaderAsync terminal is supported on SQLite. It goes through a separate, locator-free seam (the per-call storeInCache: false buffered preparation, not the SequentialAccess LOB path): the rowid locator is never appended, so FieldCount equals the projection column count and the ordinals match Select. The reader is buffered, not chunked — a byte[]/string column inside a multi-column projection is read whole into managed memory — so for a single large LOB column prefer ToStream/ToTextReader. The returned reader is caller-owned: dispose it to release the provider reader and the per-call command; the context stays alive and usable.
SQLite's own FTS3/FTS4/FTS5 query surface is available (see Full-text search), as is the SQLite-only FTS5 maintenance command builder (CreateSqliteFts5CommandBuilder); only the cross-provider SqlFunctions.Sql.contains/freetext predicates (gated by SupportsFullText, false on SQLite) stay outside it. The maintenance surface is rejected on every non-SQLite provider with NotSupportedException, and Rebuild is unavailable for contentless FTS5 tables.
Bulk insert
SQLite has no native bulk API, so every bulk write takes the portable INSERT ... VALUES path:
Mode.Native is refused by name before the source is enumerated, Auto always resolves to the portable
path, and Portable is the explicit form of the same thing. The certified configuration is a file
database (no cross-process server is involved).
- The batch is chunked by
MaxBatchSize/MaxParameters/MaxSqlLength; each chunk is one statement. KeepIdentitywrites the explicit identity column;IgnoreDuplicatesusesINSERT OR IGNORE, andReturning*usesRETURNING(SQLite 3.35+).CheckConstraints=falseis honoured by scopingPRAGMA foreign_keysaround the write and is refused inside an active transaction (the pragma is a no-op there);KeepNulls=falseneeds a default-equivalent shape, andTableLock/FireTriggersare refused.DatabaseNamerenders an attached/main/temp database qualifier where the dialect can express it;ServerNameis refused (no rerouting).Timeoutis one whole-invocation budget around the writes; an expired budget is aTimeoutExceptionand a caller cancellation always wins.
See Bulk insert.
Provider differences
| Aspect | SQLite |
|---|---|
| Parameter placeholder | $name |
| Concat | || |
| Coalesce | ifnull |
greatest / least |
max / min (scalar, 2+ arguments; returns NULL when any argument is NULL) |
| Conditional function | iif(cond, a, b) (SQLite 3.32+) |
| Window functions | percent_rank(), cume_dist(), nth_value(expr, n) supported |
| Boolean literal | 1 / 0 |
| Identifier quoting | single quotes (as 't1') |
| Derived table alias | not required |
| TVF alias | not required |
*ALL |
not supported |
ANY/ALL subqueries |
rejected by the database at execution |
| Full-text search | FTS3/FTS4/FTS5 query surface via SqlFunctions.Sqlite (Match, FTS5 FTS5bm25/Highlight/Snippet/Rank, FTS3/4 helpers, FTS5 table-valued MatchTable; FTS3/4 Rank needs a connection-registered rank UDF); plus the SQLite-only FTS5 maintenance command builder (CreateSqliteFts5CommandBuilder: AutoMerge/CrisisMerge/Merge/Optimize/Rebuild/IntegrityCheck, sync/async terminals returning the affected-row int); cross-provider contains/freetext throw |
LOB streaming (ToStream/ToTextReader) |
supported (blob / text; the source must expose rowid — a view/WITHOUT ROWID source fails with SqliteException: no such column: rowid) |
Multi-column reader (ToDataReader/ToDataReaderAsync) |
supported (buffered, locator-free; no chunked LOB; non-LOB projections and a LOB column inside a multi-column select) |
| Session/info functions | version() → sqlite_version() (no user/schema/database information) |
See also
Source: tests/nextorm.sqlite.tests/Dialect/SqliteDialectTests.cs:23,29,42,50,
tests/nextorm.sqlite.tests/SqlGeneration/SqlGenerationTests.cs:120,133,142,152,161,173,204,217,892,
tests/nextorm.integration.tests/Sqlite/SqliteSpecificTests.cs:16,30,
src/nextorm.sqlite/SqliteDialect.cs, src/nextorm.sqlite/SQLiteFunctions.cs,
src/nextorm.sqlite/SqliteDataContext.cs, src/nextorm.sqlite/DI/SqliteDataContextOptionsBuilderExtensions.cs.