Architecture deep-dive
This page is the long-form complement to How it works. It documents the implementation — how the generator framework is laid out, how the request pipeline is structured, and the design constraints that shaped both.
For the conceptual pipeline with diagrams, start at How it works; this page goes deeper into the generator framework, SQL building, and the runtime. For project status, the supported-dialect matrix, and the roadmap, see Project status and the Roadmap.
Quick reference
| Concern | Where it lives |
|---|---|
Public runtime API (IInquiry, attributes, commands, transactions, options) |
src/Inquiry/ |
| Per-dialect Roslyn generator | src/Inquiry.<Dialect>.Analyzer/ |
| Shared generator framework | src/Inquiry.Generators.Shared/ |
| Per-dialect runtime provider package | src/Inquiry.<Dialect>/ |
| SQL builder per dialect | Inquiry.Generators.Shared/SqlBuilder + dialect-specific subclasses |
| Materializer emission | Inquiry.Generators.Shared/MaterializerEmitter |
| Store-method emission | Inquiry.Generators.Shared/StoreOperationEmitter |
| Request pipeline (default) | src/Inquiry/Pipeline/InquiryRequestPipeline.cs |
| Request pipeline (transacted) | src/Inquiry/Pipeline/TransactedInquiryRequestPipeline.cs |
| Transaction handle abstractions | src/Inquiry/Transactions/IInquiryTransaction.cs, InquiryTransactionBase.cs |
| Top-level (real) transaction | src/Inquiry/Transactions/InquiryTransaction.cs |
| Savepoint (nested) transaction | src/Inquiry/Transactions/SavepointInquiryTransaction.cs |
| Generated DDL emission | Inquiry.Generators.Shared/SchemaEmitter.cs |
| DI registration emission | Inquiry.Generators.Shared/RegistrationEmitter.cs |
Key design constraints
- Compile-time SQL is non-negotiable. Every SQL statement is a
const string. The runtime never builds, formats, or interpolates SQL. - One dialect per assembly.
[InquiryDialect]isAllowMultiple = false. Multi-dialect = multi-assembly. - The runtime ships zero SQL.
src/Inquiry/has noSELECT, noINSERT, nothing. All SQL lives in the generated partials. - Materializers are struct-specialized. Generated stores call the struct-materializer overloads on the pipeline; the JIT emits a separate body per concrete struct so the per-row
materializer.Materialize(reader)call inlines (no interface dispatch). - Read streaming. Generated stores pass
CommandBehavior.SequentialAccess. Generated materializers read every column exactly once in ascending ordinal order, so this is safe and roughly halves allocation on large/wide reads. - Diagnostics at compile time. Any condition the generator can detect (unknown column, missing key, unsupported return shape, conflicting attributes) produces an
INQxxxdiagnostic at the source location.
SQL building
All SQL is produced at compile time by an internal SqlBuilder hierarchy in Inquiry.Generators.Shared. The runtime ships zero SQL — no abstract dialect, no per-call build, no statement cache. Each generated store carries the SQL it needs as private const string fields.
// src/Inquiry.Generators.Shared/Abstractions/SqlBuilder.cs
public abstract class SqlBuilder
{
public abstract string DialectName { get; }
public abstract string QuoteIdentifier(string identifier);
public virtual string ParameterName(string logical); // default: "@" + logical
public string QuoteTable(string? schema, string table);
public abstract string BuildSelectAllSql (SqlBuildContext ctx);
public abstract string BuildSelectByKeySql (SqlBuildContext ctx);
public abstract string BuildSelectByFieldSql (SqlBuildContext ctx, IReadOnlyList<IColumn> filterColumns);
public abstract string BuildInsertSql (SqlBuildContext ctx);
public abstract string BuildInsertReturningSql (SqlBuildContext ctx);
public abstract string BuildUpdateSql (SqlBuildContext ctx);
public abstract string BuildUpdateReturningSql (SqlBuildContext ctx);
public abstract string BuildDeleteByKeySql (SqlBuildContext ctx);
public abstract string BuildUpsertSql (SqlBuildContext ctx);
public abstract string BuildUpsertReturningSql (SqlBuildContext ctx);
}
StoreProcessor builds a SqlBuildContext once per (entity, builder) pair — precomputing the quoted table, the select/insert column lists and matching parameters, the SET clauses, and the key WHERE clause — then calls whichever Build…Sql methods the store actually needs. Feature capabilities (predicates, pagination, batch, soft-delete, concurrency, …) are added as virtual-with-base-default members where the SQL is dialect-uniform, so a new provider inherits them and overrides only what genuinely differs.
| Dialect builder | Identifier quoting | Upsert strategy |
|---|---|---|
SqliteSqlBuilder |
"name" (double quotes) |
INSERT … ON CONFLICT DO UPDATE |
SqlServerSqlBuilder |
[name] (brackets) |
MERGE (existence branch for generated keys) |
PostgreSqlSqlBuilder |
"name" (double quotes) |
INSERT … ON CONFLICT … DO UPDATE (client key); UPDATE/INSERT CTE (generated key) |
MySqlSqlBuilder |
`name` (backticks) |
INSERT … ON DUPLICATE KEY UPDATE |
OracleSqlBuilder |
"name" (double quotes) |
MERGE |
To change how a statement is emitted for one database without affecting the others, override the matching Build…Sql in that provider's builder.
Dialect selection
Each provider analyzer hardcodes its own dialect name. When Roslyn loads it (because the consumer referenced the matching provider package), it inspects the compilation for [assembly: InquiryDialect("…")] — first on the consuming assembly (an explicit override), then on referenced assemblies (provider runtime DLLs ship the attribute pre-applied). If the resolved name matches, the generator emits; otherwise it stays silent so a coexisting provider can claim the build. No dialect attribute at all → the loaded generator treats it as implicit opt-in to its own dialect. Multiple matching dialects surface as INQ014.
Store attributes
All store attributes live in Inquiry.Stores. The method must be a partial declaration on a partial class : InquiryStore<TEntity>, and the last parameter must be CancellationToken. The generator emits the constructor and the method bodies into a second partial of the same class — no derived class, no user-written constructor.
| Attribute | Maps to |
|---|---|
[InquirySelectAll] / [InquirySelectAllEager] |
BuildSelectAllSql (+ per-relation child queries for eager) |
[InquirySelectOneByKey] / [InquirySelectOneByKeyEager] |
BuildSelectByKeySql (+ per-relation child queries for eager) |
[InquirySelectAllByField("Field")] |
BuildSelectByFieldSql |
[InquiryInsert] returning Task<int> / Task<T?> |
BuildInsertSql / BuildInsertReturningSql |
[InquiryUpdate] returning Task<bool> / Task<T?> |
BuildUpdateSql / BuildUpdateReturningSql |
[InquiryUpsert] returning Task<int> / Task<T?> |
BuildUpsertSql / BuildUpsertReturningSql |
[InquiryDelete] |
BuildDeleteByKeySql |
[InquiryStoredProcedure("Proc")] |
raw InquiryCommand with CommandType.StoredProcedure |
Entity-mapping attributes live in Inquiry.Entities: [InquiryTable], [InquiryColumn], [InquiryKey], [InquiryForeignKey], [InquiryRelation]. Beyond this core surface Inquiry also supports richer WHERE predicates, ORDER BY + pagination, batch operations, projections + aggregations, optimistic concurrency, soft deletes, full-text search, and value-converter columns — see Features.
Transactions
IInquiry.ExecuteInTransactionAsync is a public helper over the same primitive: it opens a transaction, awaits the caller's delegate, commits on success, and lets dispose roll back on exceptions. IInquiry.BeginTransactionAsync opens a fresh DbConnection + DbTransaction from the connection factory and returns an IInquiryTransaction. Two implementation classes back the interface:
InquiryTransaction— the top-level case. Owns the connection and theDbTransaction.CommitcallsDbTransaction.CommitAsync,RollbackcallsRollbackAsync,Disposerolls back if neither has fired and then disposes the connection.SavepointInquiryTransaction— the nested case. Holds a reference to the outerTransactedInquiryRequestPipelineplus a unique savepoint name.CommitcallsDbTransaction.ReleaseAsync(name);RollbackcallsRollbackAsync(name);Disposebest-effort rolls back to the savepoint if neither has fired. Oracle's lack of explicit savepoint release is handled by catchingNotSupportedExceptioninCommit— the savepoint will be released implicitly when the outer transaction closes.
Both inherit from InquiryTransactionBase, which holds the root IInquiry privately and implements every forwarding method (tx.ExecuteAsync, tx.QueryAsync<T>, etc.) once. Each forwarding method calls the concrete's ThrowIfClosed() before delegating — that's how use-after-close throws ObjectDisposedException instead of silently routing to the non-transactional pipeline.
Ambient routing — how generated stores join
DefaultInquiry holds an AsyncLocal<AmbientTransactionSlot> field. The slot is a holder reference with a mutable Pipeline field — not the pipeline directly. That extra indirection exists because AsyncLocal values set inside an async callee don't propagate back to the caller. BeginTransactionAsync works around it:
- Install the slot synchronously, before any
await. Caller's async context now sees the holder. - Await the connection open +
BeginTransactionAsync(level, ct)call. - Fill in
slot.Pipeline = new TransactedInquiryRequestPipeline(connection, tx, …). The caller's reference to the holder sees the mutation. - Return an
InquiryTransactionwhose close path detaches the current async flow, then marks the captured holder closed on the first of Commit / Rollback / Dispose.
Every IInquiry method then routes through the ambient slot. Active slot → transacted pipeline reusing one connection. No slot → default pipeline opening a fresh connection per call. Closed captured slot → ObjectDisposedException, which prevents async work that started inside a transaction from silently continuing outside it after close. Generated stores (which hold the same DI-scoped DefaultInquiry) participate automatically without per-call wiring.
The nested case in BeginTransactionAsync short-circuits: if _ambientSlot.Value?.Pipeline is already set, it doesn't open a new physical transaction; it calls outerPipeline.SaveSavepointAsync(name, ct) and returns a SavepointInquiryTransaction. The slot stays pointing at the outer pipeline — savepoints share its physical connection.
Concurrency guard
DbConnection isn't thread-safe. TransactedInquiryRequestPipeline serializes access with an Interlocked.CompareExchange(ref _inFlight, 1, 0) guard at the top of every operation. A second op starting while another is in flight throws InvalidOperationException("Cannot start a new Inquiry operation while another operation is in flight on the same transaction.") instead of corrupting the connection.
Root commit / rollback and the savepoint primitives (SaveSavepointAsync / ReleaseSavepointAsync / RollbackToSavepointAsync) respect the same guard — they're SQL statements on the connection, just like data ops.
DI lifetimes
IInquiry(DefaultInquiry) — Scoped. Each DI scope gets its own ambient slot; transactions don't cross scopes.IInquiryRequestPipeline(InquiryRequestPipeline) — Scoped. Used byDefaultInquiryas the non-transactional fallback.IInquiryConnectionFactory— Singleton. Owns the connection-string + dialect-specific factory logic.- Generated stores — Scoped. Hold the same scoped
IInquiryinstance the rest of the scope uses. - Materializers — Singleton. Stateless; safe to share.
What's not modeled
System.Transactionsambient (TransactionScope) — Inquiry uses ADO.NETDbTransactiondirectly. Cross-process / DTC scenarios are out of scope for a single-engine micro-ORM. If you need to participate in aTransactionScope, open it before resolving Inquiry and let the provider'sDbConnectionauto-enlist (per its own ADO.NET behavior — the connection will pick upTransaction.Currentwhen opened).- Cross-DI-scope transactions — each scope has its own
DefaultInquirywith its ownAsyncLocalfield; ambient transactions don't leak between scopes. - Cross-thread transactions without
ExecutionContextflow —Task.Runpreserves the context by default, so ambient transactions survive it.Thread.Startand manual context-suppression do not.