-
Notifications
You must be signed in to change notification settings - Fork 2
Source Generator
SQLite.Framework.SourceGenerator is an optional package that produces materializers for your entities, Select projections and projected JSON collection results at build time. A materializer is the small piece of code that reads column values from a SQLite row and builds a .NET object out of them.
Without the source generator, SQLite.Framework walks the expression tree of every query at runtime and uses reflection to create the result objects. That works fine on normal .NET but it has two costs. Every row goes through reflected constructor, property and method calls, so startup and per-query cost stay higher than they need to be. And the expression tree methods the C# compiler generates for a Select (like Expression.New and Expression.Bind) are annotated with [RequiresUnreferencedCode], which produces trimmer warnings under PublishAot. The trimmer can also strip types that are only reached through reflection.
The source generator solves both. It reads your code at build time and writes plain C# that creates the objects directly. Every public type or method the generator can see is referenced by name, so the trimmer keeps it and the reflected materializer path is not used for those.
Reflection is still used in two narrow cases. One is a Select or entity target that is private or internal, so the generated code cannot name it. The other is a Select body that calls a private method. In those cases the generator falls back to MethodInfo.Invoke / Activator.CreateInstance on types and members that are captured at query-build time. If you want the reflected path off your hot path entirely, keep the types and methods that appear in your Select projections public or internal with InternalsVisibleTo.
The source generator is what makes Native AOT (PublishAot) work without the reflected materializer path. It also lowers cold-start query cost and keeps the trimmer happy on every shape it covers. Without it the runtime path still works through reflection.
The generated materializers skip the runtime expression-tree walk and use typed reader accessors that avoid the boxing that the runtime path goes through. The savings depend on the shape of the query.
End-to-end query. The source-generated path is up to 24% faster and uses up to 37% less allocated memory than the runtime path:
| Path | Mean | Allocated |
|---|---|---|
| Runtime | 148.86 us | 55.55 KB |
| Source generator | 117.12 us | 35.08 KB |
Materialization only (with SQLite execution stubbed out). The source-generated path is up to 63% faster than the runtime path. This is just the part of the query that the source generator actually changes.
| Path | Mean | Allocated |
|---|---|---|
| Runtime | 18.86 us | 30.38 KB |
| Source generator | 6.96 us | 24.52 KB |
Rules of thumb for what to expect:
- Small list queries see the biggest wins. The fixed cost of building a materializer at runtime, plus the per-row boxing the runtime path does, both fall away.
-
Scalar queries (a
Count,Sum,Firstof a primitive) see little or no change. There is nothing to materialize. - Heavy queries with many joins, subqueries or large result sets are dominated by the time SQLite spends executing the SQL. The fixed savings stay the same in absolute terms, so the percentage shrinks. Allocation savings are still visible.
Install the package next to SQLite.Framework:
dotnet add package SQLite.Framework.SourceGeneratorIt is a build-time only package. It does not add a runtime dependency to your app.
Call UseGeneratedMaterializers on your SQLiteOptionsBuilder:
using SQLite.Framework;
using SQLite.Framework.Generated;
SQLiteOptions options = new SQLiteOptionsBuilder("app.db")
.UseGeneratedMaterializers()
.Build();
using SQLiteDatabase db = new(options);UseGeneratedMaterializers is an extension method written by the generator itself. It lives in the SQLite.Framework.Generated namespace, so add the using line shown above. It registers the generated entity, projection, grouping and JSON collection helpers on the builder. After that, every query uses the generated code and falls back to the runtime path only for shapes the generator does not cover yet.
That is all the setup that is needed. Write LINQ queries the same way you do without the generator:
var titles = await db.Table<Book>()
.Where(b => b.Price < 30)
.Select(b => new { b.Id, b.Title })
.ToListAsync();The anonymous type { Id, Title } gets a materializer at build time.
If you want a hard guarantee that the source generator covers every query in production, call DisableReflectionFallback on the builder:
SQLiteOptions options = new SQLiteOptionsBuilder("app.db")
.UseGeneratedMaterializers()
.DisableReflectionFallback()
.Build();With this set, any query that would otherwise use the runtime reflection path throws an InvalidOperationException at the moment the query runs. This covers entity materialization, Select projections and projected collection results whose concrete collection type the generator did not register.
With the flag on, unsupported shapes fail in your test suite instead of falling back. With it off, they fall back to the runtime path.
UseReflectionMaterializer is the opposite escape hatch. Call it on a single query to skip the source-generated materializer for that query and build the result with runtime reflection instead:
var titles = await db.Table<Book>()
.Where(b => b.Price < 30)
.Select(b => new { b.Id, b.Title })
.UseReflectionMaterializer()
.ToListAsync();The generator runs in every project that references the package and produces one SQLiteFrameworkGeneratedMaterializers class per project. The class and its UseGeneratedMaterializers method are internal, so they are only visible inside the project that built them.
This means:
- If your solution has several projects that build LINQ queries (for example a Web API project, a background worker and a shared data library that only exposes
IQueryablehelpers), each project that callsUseGeneratedMaterializersneeds its own reference toSQLite.Framework.SourceGenerator. The generated class in project A cannot be called from project B. - The generator only sees entities and
Selectprojections that appear in the project it is building. ASelectwritten in a different project will use the runtime path unless that other project also has the generator installed and also callsUseGeneratedMaterializerson its own builder. - It is fine to call
UseGeneratedMaterializersmore than once on the same builder (for example once per library that contributes queries). Later calls replace entries in the dictionaries for the same signature, so the last registration wins.
If all your queries live in one project (the common case for small apps), install the package there and call UseGeneratedMaterializers once at startup. That is the whole setup.
The generator produces several helpers. Its three result-materialization paths are:
Entity materializers map a row to a .NET class. The generator scans every db.Table<T>(), db.Query<T>, db.FromSql<T>, db.With<T>, .Cast<T>(), .OfType<T>() and command.ExecuteQuery<T>() call to find target types. It also scans Select and SelectMany projection result types and the types produced by select clauses in query syntax. Nested private and file sealed classes work through a reflection-based materializer that is still registered per type, so the runtime never falls back.
Select materializers cover the body of a Select, SelectMany, Join or GroupBy key selector. This includes:
- Anonymous types like
Select(b => new { b.Id, b.Title }) - Object initialisers like
Select(b => new BookView { Id = b.Id, Title = b.Title }) - Object initialisers with nested entity construction like
Select(b => new BookDto { Id = b.Id, Author = new AuthorDto { ... } }) - Method calls on rows, including your own methods like
Select(b => FormatTitle(b)) - Captured locals from the surrounding method like
Select(b => new { b.Id, Prefix = prefix + b.Title }) - Joins and group joins written in query syntax.
- Anonymous types returned from chains, with correct member names preserved.
JSON collection materializers read a projected List<T>, T[] or HashSet<T> without runtime reflection. The generator discovers the concrete result type from a Select projection.
The generator also emits GroupBy key and grouping-query helpers plus entity column writers.
Shapes that still fall back to the runtime path include anonymous types whose members use a type with a custom converter (for example a user-defined struct bound through AddTypeConverter). Turn on DisableReflectionFallback to make the first such query throw instead of silently using reflection.
The generator follows generic methods and generic classes whose body wraps ExecuteQuery<T> or a Select projection. For each concrete instantiation it sees somewhere in the project, it emits one materializer keyed by the closed type. Two patterns are covered:
A generic class wrapping ExecuteQuery<T>:
public class Repo<T>
{
private readonly SQLiteDatabase db;
public Repo(SQLiteDatabase db) => this.db = db;
public List<T> Get(string sql)
=> db.CreateCommand(sql, []).ExecuteQuery<T>().ToList();
}
// Elsewhere in the same project:
List<Book> books = new Repo<Book>(db).Get("SELECT * FROM Books");
List<Author> authors = new Repo<Author>(db).Get("SELECT * FROM Authors");The generator records new Repo<Book>() and new Repo<Author>(), then walks Repo<T>.Get's body, sees ExecuteQuery<T> with an open T and emits an entity materializer for Book and one for Author. Adding new Repo<Customer>() later automatically gets a Customer materializer the next time the generator runs.
A generic method projecting through Select:
private static Task<TResult> ProjectFirst<T, TResult>(IQueryable<T> query)
where T : INomenclature
where TResult : NomenclatureDtoBase, new()
=> query.Select(f => new TResult { Id = f.Id, Name = f.Name }).FirstAsync();
// Callers:
DtoA aDto = await ProjectFirst<NomenclatureA, DtoA>(db.Table<NomenclatureA>());
DtoB bDto = await ProjectFirst<NomenclatureB, DtoB>(db.Table<NomenclatureB>());The generator records each closed call (<NomenclatureA, DtoA>, <NomenclatureB, DtoB>), substitutes the type parameters into the lambda body and emits one Select materializer per concrete TResult.
What the generator can follow:
- Both class-level (
Repo<T>) and method-level (Run<T>) type parameters, plus the cross product when both are generic. - Transitive cases where one generic helper calls another, as long as the chain stays inside the same project.
- Constraint-based member access (
f.Idwheref : T, T : INomenclature). The generator emits the sameConvert(f, INomenclature)shape the runtime expression tree builds for the closed call.
What is out of scope:
- The generator cannot follow a helper into another assembly. If
Repo<T>lives in a referenced library, it only sees the compiled signature, not the body and cannot tell that the helper callsExecuteQuery<T>. Move the helper into the project that runs the generator or pre-callExecuteQuery<ConcreteType>()directly. - A helper with no concrete callsite in the same project gives the generator nothing to substitute, so the runtime path is used.
- Reflection inside the helper body (
Activator.CreateInstance(typeof(TResult))and similar) is not followed. The generator only follows realnew TResult { ... }syntax.
If you publish with PublishAot=true, the source generator is the recommended way to keep the reflected materializer path out of your queries. See Native AOT for the full AOT setup, including the trimmer descriptor and the [UnconditionalSuppressMessage] usage on methods that build expression trees directly.
For each db.Table<T>() call and each Select(...) lambda in your code, the generator emits methods that read the right columns and create the result. At runtime, SQLite.Framework looks them up by entity type, projection signature or declared collection result type. If no generated helper covers the shape, it builds the materializer through reflection.
For generic helpers, the generator additionally builds an index of every closed type-argument tuple it sees at any callsite of every generic method and generic class in the project. When a helper's body uses an open type parameter as the projection or ExecuteQuery<T> argument, the generator substitutes each tuple from the index and emits one materializer per concrete substitution.
You do not need to know any of this to use the package. Just install it and call UseGeneratedMaterializers.