/DB

Declaring table types

Declare a column shape once with [TableType], bind the table name at runtime, and get fully typed results. The right tool for per-tenant, sharded, and time-partitioned tables.

updated 5 Jun 20262 min readv0.3.2View as Markdown

Some tables are created at runtime: per tenant, per period, or sharded (audit_2026_06, tenant_42_orders). Their names are not known at build time, but their column shape usually is. A [TableType] class lets you declare that shape once and bind the name at runtime, returning the typed entity. Only the name is dynamic, so the whole feature stays NativeAOT-safe.

Declaring a table type

using Socigy.OpenSource.DB.Attributes;

[TableType]
public partial class AuditEntry          // the generator implements IDbTableType<AuditEntry>
{
    [PrimaryKey, Default(DbDefaults.Guid.Random)]
    public Guid Id { get; set; }
    public Guid UserId { get; set; }
    public required string Action { get; set; }
    public int Amount { get; set; }
    public DateTime At { get; set; }
}

Columns use the same attributes as [Table] ([PrimaryKey], [Column], [JsonColumn], [Encrypted], and the rest). A class may carry both [Table("default")] and [TableType]: a fixed default name plus a runtime override.

The generator emits two static entry points on the class:

Method Returns Use
AuditEntry.WithTableName(string name) DynamicTable<AuditEntry> bind a runtime name
AuditEntry.MapTypeAsync(string name, DbConnection conn, bool force = false, CancellationToken ct = default) Task<DynamicTable<AuditEntry>> bind and auto-discover extra columns (see Runtime operations)

Querying and writing without a context

Bind the runtime name with WithTableName, attach a connection, then use the full typed API. DynamicTable<T> lives in Socigy.OpenSource.DB.Core.Dynamic.

await using var conn = factory.Create();
await conn.OpenAsync();

var t = AuditEntry.WithTableName("audit_2026_06");

List<AuditEntry> rows = await t.WithConnection(conn).Query(x => x.UserId == id).ToListAsync();
AuditEntry? first    = await t.WithConnection(conn).Query(x => x.Amount > 100).FirstOrDefaultAsync();
long count           = await t.WithConnection(conn).Query(x => x.Action == "login").CountAsync();
int sum              = await t.WithConnection(conn).SumAsync<int>(x => x.Amount) ?? 0;

await t.WithConnection(conn).InsertAsync(new AuditEntry { UserId = id, Action = "login", At = DateTime.UtcNow });
await t.WithConnection(conn).InsertMultipleAsync(batch);                 // batched multi-row INSERT
await t.WithConnection(conn).UpdateAsync(changed, x => x.Id == changed.Id);
await t.WithConnection(conn).DeleteAsync(x => x.At < cutoff);
NOTE
Each WithTableName(...) returns a fresh handle. Build one per query rather than reusing a handle that already has a predicate.

The handle exposes the full surface: Query/Where, OrderBy, Limit/Offset, WithCustomColumns; the readers ExecuteAsync (stream), ToListAsync, FirstOrDefaultAsync, ExistsAsync, CountAsync; the aggregates SumAsync/AvgAsync/MinAsync/MaxAsync/ScalarAsync; the writers InsertAsync/InsertMultipleAsync/UpdateAsync/DeleteAsync; and the lifecycle methods covered in Runtime operations.

Through the context

The generated context exposes a generic DynamicTable<T>(name) that joins the unit-of-work scope, so it enlists the ambient transaction and shares the connection:

await db.ExecuteTransactionAsync(async d =>
{
    await d.DynamicTable<AuditEntry>("audit_2026_06").InsertAsync(entry);
    long count = await d.DynamicTable<AuditEntry>("audit_2026_06")
        .Query(x => x.UserId == id)
        .CountAsync();
});
WARNING
The runtime table name is a SQL identifier you supply (quoted automatically). Treat it like a [Table] name, never as untrusted user input.