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 compile alongside your own code. "Incremental" means the generator tracks which inputs changed between builds and re-runs only the affected parts, so repeated builds stay fast even in large solutions. Nothing runs at application startup. Everything the generator produces is plain C# 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], and so on), plus C# nullable annotations (string?,int?). - 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.
DbDefaults live in Socigy.OpenSource.DB.Attributes. The one exception is [Nullable], which lives in Socigy.OpenSource.DB.Core.Attributes.While it reads your tables and .sql files, the generator validates them and reports problems as standard SCGDB### build diagnostics: unsupported attribute combinations, missing primary keys, malformed procedure headers, unresolvable schema placeholders, and more. See Generator diagnostics for the full catalog.
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 call:
- 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 Type Type { get; set; } // CLR type of the column
public object? Value { get; set; } // current value from the row instance (after convertor)
public bool IsPrimaryKey { get; set; } // part of the primary key
public bool IsAutoIncrement { get; set; } // sequence-backed; excluded from INSERT by default
public bool HasDbDefault { get; set; } // has a DB DEFAULT expression from [Default]
public bool IsJson { get; set; } // stored as jsonb
public bool IsEncrypted { get; set; } // [Encrypted]; stored as bytea (ciphertext)
public Action<object?>? SetValue { get; set; } // writes a value read back from the DB into the row
public static T? ApplyDbValue<T>(object? dbValue); // converts a raw DB value to T
}SetValue is used by WithValuePropagation() to write DB-generated values (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 convert 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 only classes whose declaration or attribute metadata changed get re-processed. In practice:
- 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 on disk:
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 straight to the generated file. No extra IDE plugin needed.Multiple database engines
The library is built around an engine-agnostic core. Everything the generator reads is independent of any single database: the [Table] and column attributes, the IDbTable contract, the ColumnInfo model, the WHERE expression-tree analysis, and the query/command builder abstractions. The PostgreSQL-specific pieces (the Postgresql*CommandBuilder<T> types, the DDL dialect, the type mapping) sit in a thin layer on top.
The core is ready for multiple engines today. The per-engine implementations simply haven't been written yet. PostgreSQL is currently the only engine with a complete implementation, which is why every generated builder you see carries the Postgresql prefix.
This is a deliberate design goal, not a limitation baked into the architecture. Adding another engine (MySQL, SQLite, SQL Server) means implementing that engine's builders and dialect against the existing core abstractions. It does not require touching the source generator or the core model.
AOT compatibility
The generator emits code fully compatible with .NET Native AOT. Nothing at runtime uses reflection:
GetColumns()is a hand-written dictionary-building method, with noPropertyInfoscanning.- WHERE expression trees in
Query(x => ...)compile to parameterized SQL in the query builder, never 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.