/DB

How it works

A deep dive into the Roslyn incremental source generator — what it reads, what it emits, and how to inspect the output.

updated 3 May 20264 min readv0.1.82

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:

  1. Scans every class in the compilation that carries a [Table] attribute.
  2. 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.).
  3. Emits one companion .g.cs file per annotated class into obj/{Configuration}/net{version}/generated/Socigy.OpenSource.DB.SourceGenerator/Socigy.OpenSource.DB.SourceGenerator.Program/.
  4. Separately processes <AdditionalFiles> .sql files 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:

  1. Reads the current value of every mapped property on this.
  2. Applies any [ValueConvertor] transformation to the value.
  3. Packages the result into a ColumnInfo struct 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 needed

Async 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.cs does not re-generate User.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.

TIP
In Visual Studio and JetBrains Rider, pressing F12 (Go to Definition) on a generated method (e.g. 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 — no PropertyInfo scanning.
  • 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 JsonSerializerContext you provide, which is also AOT-safe.

This makes Socigy.OpenSource.DB suitable for publishing with PublishAot=true without trimmer warnings from the DB layer.