/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 5 Jun 20266 min readv0.3.2View as Markdown

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:

  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], and so on), plus C# nullable annotations (string?, int?).
  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.

NOTE
The table attributes and 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:

  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 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 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 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.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 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.

TIP
In Visual Studio and JetBrains Rider, pressing F12 (Go to Definition) on a generated method such as 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.

NOTE
Multi-engine support is on the roadmap, and the groundwork is already in place. If you need an engine that isn't here yet, nothing blocks you from contributing it: the core is intentionally open for exactly this. Contributions are welcome on GitHub.

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 no PropertyInfo scanning.
  • WHERE expression trees in Query(x => ...) compile to parameterized SQL in the query builder, never 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.