How it works
A deep dive into the Roslyn incremental source generator — what it reads, what it emits, and how to inspect the output.
What a Roslyn incremental source generator is
A Roslyn incremental source generator is a .NET SDK feature that plugs into the C# compiler pipeline. It receives syntax trees and semantic models for every file in your project, runs arbitrary analysis, and writes additional .cs files that are compiled alongside your own code. The word "incremental" means the generator tracks which inputs changed between builds and only re-runs the parts that are affected — making repeated builds fast even in large solutions. No code runs at application startup; everything the generator produces is plain C# that is compiled at build time.
What the generator does at build time
When you run dotnet build, the generator:
- Scans every class in the compilation that carries a
[Table]attribute. - Reads the attribute arguments (table name, schema) and inspects each property's attributes (
[PrimaryKey],[Default],[Column],[ValueConvertor],[Json], etc.), as well as C# nullable annotations (string?,int?, etc.). - Emits one companion
.g.csfile per annotated class intoobj/{Configuration}/net{version}/generated/Socigy.OpenSource.DB.SourceGenerator/Socigy.OpenSource.DB.SourceGenerator.Program/. - Separately processes
<AdditionalFiles>.sqlfiles to emit procedure wrapper methods (see Procedure mapping).
The compiler sees the generated files as ordinary source — no reflection, no dynamic proxy, no IL weaving.
What each generated file contains
IDbTable implementation
Every generated file makes the class implement IDbTable:
public interface IDbTable
{
string GetTableName();
Dictionary<string, ColumnInfo> GetColumns();
Dictionary<string, ColumnInfo> GetPrimaryColumns();
(string Name, ColumnInfo Info)? GetColumn(string name);
string? GetDbColumnName(string memberName);
}| Method | What it returns |
|---|---|
GetTableName() |
The SQL table name from [Table("...")] |
GetColumns() |
A Dictionary<string, ColumnInfo> built at call time (see below) |
GetPrimaryColumns() |
A Dictionary<string, ColumnInfo> containing only the [PrimaryKey] columns |
GetColumn(name) |
A (string Name, ColumnInfo Info)? tuple for the given C# property name, or null |
GetDbColumnName(name) |
The snake_case DB column name for a given C# property name |
GetColumns() — the runtime column map
GetColumns() is the core of the query infrastructure. Each time it is called it:
- Reads the current value of every mapped property on
this. - Applies any
[ValueConvertor]transformation to the value. - Packages the result into a
ColumnInfostruct with metadata flags.
The returned Dictionary<string, ColumnInfo> is keyed by snake_case DB column name.
ColumnInfo struct
public struct ColumnInfo
{
public object? Value { get; } // current property value (after convertor)
public Action<object?> SetValue { get; } // writes back to the property (used by RETURNING)
public bool IsJson { get; } // column is stored as JSON
public bool IsPrimaryKey { get; } // column is part of the primary key
public bool HasDbDefault { get; } // column has a [Default] attribute or C# initializer
}SetValue is used by WithValuePropagation() to write DB-generated values (e.g. auto-UUID, server timestamp) back into the C# object after an INSERT.
Instance builder methods
The builder methods live on the entity instance. Call them on an object you already have:
user.Insert() // PostgresqlInsertCommandBuilder<User>
user.Update() // PostgresqlUserUpdateCommandBuilder<User>
user.Delete() // PostgresqlUserDeleteCommandBuilder<User>Static query and delete methods
static TableQueryBuilder User.Query();
static TableQueryBuilder User.Query(Expression<Func<User, bool>> predicate);
static PostgresqlUserDeleteCommandBuilder<User> User.DeleteNonInstance(); // filtered delete, no instance neededAsync static shorthands
One-liner shortcuts for the common case (no builder needed):
static Task<bool> User.InsertAsync(User instance, DbConnection connection);
static Task<int> User.UpdateAsync(User instance, DbConnection connection);Column name constants
The generator emits a {PropertyName}ColumnName constant directly on the class for every mapped property, using the snake_case database column name:
User.IdColumnName // => "id"
User.UsernameColumnName // => "username"
User.CreatedAtColumnName // => "created_at"Property names are converted to snake_case automatically. These constants are useful when building dynamic SQL fragments or constructing ORDER BY / GROUP BY clauses without hard-coded strings.
Incremental builds
The generator participates in Roslyn's incremental pipeline. It registers syntax predicates and semantic transforms so that only classes whose declaration or attribute metadata changed are re-processed. In practice this means:
- Changing
Product.csdoes not re-generateUser.generated.cs. - Adding a new
[Table]class triggers generation only for that class. - Cold builds regenerate everything; warm builds regenerate only diffs.
Inspecting generated code
Generated files are readable C# placed under the intermediate output path. Add <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles> to your .csproj to make them visible in the file system:
obj/{Configuration}/net{version}/generated/Socigy.OpenSource.DB.SourceGenerator/Socigy.OpenSource.DB.SourceGenerator.Program/Opening these files is the most direct way to diagnose unexpected query behavior — you can see exactly which columns are included, how the WHERE clause is built, and which parameters are parameterized.
user.Insert()) navigates directly to the generated file. This works without any extra IDE plugin.AOT compatibility
The generator emits code that is fully compatible with .NET Native AOT. Nothing at runtime uses reflection:
GetColumns()is a hand-written dictionary-building method — noPropertyInfoscanning.- WHERE expression trees in
Query(x => ...)are compiled to parameterized SQL by the query builder at build time, not reflected over at runtime. - JSON columns serialize through a
JsonSerializerContextyou provide, which is also AOT-safe.
This makes Socigy.OpenSource.DB suitable for publishing with PublishAot=true without trimmer warnings from the DB layer.