Procedure Mapping
Write raw SQL files and get back strongly-typed C# methods generated at compile time.
Overview
Procedure mapping lets you write plain SQL in .sql files, annotate them with a small header, and receive a generated C# method that runs the query with typed parameters and returns a typed result. No boilerplate DbCommand setup, no manual parameter binding.
This is the right tool for complex queries that are inconvenient to express through the fluent query builder: multi-table joins, aggregates, CTEs, window functions, or stored procedure calls.
Project Setup
Declare your .sql files as <AdditionalFiles> in the .csproj so the Roslyn source generator can read them at compile time:
<ItemGroup>
<AdditionalFiles Include="Socigy\Procedures\**\*.sql" />
</ItemGroup>The path prefix Socigy\Procedures\ is *required**, not a convention. The source generator only discovers .sql files located under Socigy/Procedures/, so the glob must be `Socigy\Procedures**.sql`. Files placed in any other directory are ignored.
Recommended File Structure
MyApp.DB/
└── Socigy/
└── Procedures/
├── GetUserLoginByUsername.sql
├── Admin/
│ ├── GetUsersByRole.sql
│ └── DeactivateUser.sql
└── Reporting/
└── GetMonthlyStats.sqlSubdirectories translate to nested static classes in the generated output (see Subdirectory Organisation below).
SQL File Header Syntax
Every .sql file may contain a header block of -- @ comment lines before the SQL statement. By convention, @returns comes first, followed by @param lines, but the parser accepts either order.
-- @returns: ReturnTypeName
-- @param paramName: CSharpType| Header line | Required | Description |
|---|---|---|
-- @param name: Type |
One per parameter | Name and fully-qualified C# type |
-- @returns: FullTypeName |
No | C# type mapped to each result row |
-- @ignore warning[: reason] |
No | Suppresses the missing-placeholder warning (SCGDB003) for this file. See Suppressing the placeholder warning. |
- If
@returnsis omitted, the generated method returnsTask<bool>(true on success). - If
@returnsis present, the generated method returnsIAsyncEnumerable<ReturnType>.
Parameter names used in the SQL body (@username) must match the -- @param names exactly.
-- @param that is never used, an @name in the body with no matching -- @param, an unresolvable -- @returns type, and more. See Generator diagnostics for the full list.Example: Void Procedure (No @returns)
A procedure that performs a write and returns no rows.
-- @param userId: System.Guid
-- @param reason: string
UPDATE "user_logins"
SET "deactivated_at" = timezone('utc', now()),
"deactivation_reason" = @reason
WHERE "id" = @userIdGenerated signature:
public static Task<bool> DeactivateUser(
DbConnection conn,
System.Guid userId,
string reason)Example: Query with Return Type
A procedure that returns typed rows.
-- @returns: MyApp.UserLogin
-- @param username: string
SELECT "id", "username", "password_hash"
FROM "user_logins"
WHERE "username" = @usernameGenerated signature:
public static async IAsyncEnumerable<UserLogin> GetUserLoginByUsername(
DbConnection conn,
string username,
[EnumeratorCancellation] CancellationToken cancellationToken = default)Schema Placeholders
Hard-coding table and column names in raw SQL means a renamed C# property (or a [Table("...")] / [Column("...")] override) silently drifts away from your queries until something breaks at runtime. To keep SQL tied to your schema, reference them through optional placeholders that the generator expands at build time to the real, double-quoted database names:
| Placeholder | Expands to | Resolves like |
|---|---|---|
{{Type}} |
the table name ("users") |
the Type.TableName constant / [Table("...")] |
{{Type.Property}} |
the column name ("username") |
the Type.{Property}ColumnName constant / [Column("...")] |
-- @returns: MyApp.UserLogin
-- @param username: string
SELECT {{UserLogin.Id}}, {{UserLogin.Username}}, {{UserLogin.PasswordHash}}
FROM {{UserLogin}}
WHERE {{UserLogin.Username}} = @usernamebecomes, in the generated method:
SELECT "id", "username", "password_hash"
FROM "user_logins"
WHERE "username" = @usernameHow a placeholder resolves:
Typeis resolved against your compilation. Use the simple name (UserLogin) or, when that is ambiguous, a fully-qualified name (MyApp.UserLogin). It must be a[Table](or[FlagTable]) class.- A single segment (
{{UserLogin}}) is a table reference; two or more ({{UserLogin.Username}}) is a column reference. A fully-qualified table name such as{{MyApp.UserLogin}}is recognised becauseMyAppis a namespace, not a type, so it still resolves to the table. Propertyis the C# property name. It resolves to the same value as the generated{Property}ColumnNameconstant, so a[Column("login_name")]override or snake_case conversion is honoured automatically.- The expansion is always the double-quoted identifier (
"users","username"), matching PostgreSQL quoting rules.
Placeholders are entirely optional and can be mixed freely with hand-written names. Any malformed or unresolvable placeholder is reported as a build error. See SCGDB004 to SCGDB008.
TableName and {Property}ColumnName constants, a rename in C# propagates to every .sql file that references it on the next build, with no manual find-and-replace.Suppressing the placeholder warning
To encourage keeping SQL in sync with the schema, the generator emits a build warning (SCGDB003) for any .sql file that contains no {{Type.Property}} placeholder at all. The warning is attached to the .sql file itself, so it surfaces in the IDE and in build output.
When a file legitimately needs no placeholders (a query over a view, a one-off maintenance script, or a table you reference by literal name on purpose), opt out per-file with the -- @ignore warning header directive:
-- @ignore warning: Reporting view, columns are fixed by contract
-- @returns: MyApp.MonthlyStat
SELECT "month", "total" FROM "v_monthly_stats"The text after the colon is a free-form reason for your team; only the presence of -- @ignore warning matters to the generator.
To change the warning project-wide instead of per file, set its severity from .editorconfig. For example, to make a missing placeholder a hard build error:
# .editorconfig
[*.sql]
dotnet_diagnostic.SCGDB003.severity = errorSee Generator diagnostics → Configuring severity for details.
Generated Class Name
The source generator produces a single class for the entire project:
{AssemblyName}.Socigy.Generated.ProceduresFor a project with assembly name MyApp.DB, the full type name is MyApp.DB.Socigy.Generated.Procedures.
Subdirectory Organisation
Files in subdirectories become nested static classes inside Procedures. The subdirectory name is used as the nested class name.
Procedures/Admin/GetUsersByRole.sqlbecomes:
Procedures.Admin.GetUsersByRole(connection, ...)Multiple levels of nesting are supported:
Procedures/Reporting/Monthly/GetStats.sql
→ Procedures.Reporting.Monthly.GetStats(connection, ...)Calling Generated Methods
Streaming with await foreach
await foreach (var login in Procedures.GetUserLoginByUsername(conn, "alice"))
{
Console.WriteLine(login.PasswordHash);
}Collecting to a list
using System.Linq;
var logins = await Procedures.GetUserLoginByUsername(conn, "alice")
.ToListAsync();ToListAsync() requires a reference to System.Linq.Async (NuGet package System.Linq.Async).Calling a void procedure
bool ok = await Procedures.Admin.DeactivateUser(conn, userId, "Terms of service violation");Supported Parameter Types
| C# type | Example |
|---|---|
string |
names, text |
int |
counts, IDs |
bool |
flags |
System.Guid |
UUIDs |
System.DateTime |
timestamps |
System.DateOnly |
calendar dates |
System.TimeOnly |
time-of-day values |
Any type that Npgsql accepts as a command parameter value is valid.
Full Example: Get User Login by Username
File: Socigy/Procedures/GetUserLoginByUsername.sql
-- @returns: MyApp.UserLogin
-- @param username: string
SELECT {{UserLogin.Id}}, {{UserLogin.Username}}, {{UserLogin.PasswordHash}}, {{UserLogin.CreatedAt}}
FROM {{UserLogin}}
WHERE {{UserLogin.Username}} = @username
AND {{UserLogin.DeactivatedAt}} IS NULL
LIMIT 1Usage in an endpoint:
app.MapPost("/login", async (LoginRequest req, IDbConnectionFactory factory) =>
{
await using var conn = factory.Create();
await conn.OpenAsync();
await foreach (var login in Procedures.GetUserLoginByUsername(conn, req.Username))
{
if (!PasswordHasher.Verify(req.Password, login.PasswordHash))
return Results.Unauthorized();
return Results.Ok(new { login.Id });
}
return Results.NotFound();
});await foreach with a break or return inside the loop body. There is no single-row overload: the generator always returns IAsyncEnumerable<T> for queries with @returns.