How Inquiry works
Inquiry is built on one core idea: SQL is data that should be computed at build time, not at run time. This page walks through the pipeline that makes that work.
The compile-time pipeline
┌─────────────────────────────────────────────────────────────────────────────┐
│ Your assembly │
│ │
│ [assembly: InquiryDialect("Sqlite")] │
│ │
│ [InquiryTable("Shippers")] public partial class ShipperStore │
│ public class Shipper { ... } : InquiryStore<Shipper> │
│ { [InquirySelectAll] ... } │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ Inquiry.<Dialect>.Analyzer (Roslyn incremental source generator) │
│ │
│ 1. Discover entities (classes with [InquiryTable]) │
│ 2. Discover stores (partial classes : InquiryStore<T>) │
│ 3. For each store method: │
│ - resolve columns / predicates / order keys │
│ - run the per-dialect SqlBuilder │
│ - emit `const string _sql... = "...";` │
│ - emit a typed partial method body that calls the pipeline │
│ 4. Emit the entity materializer (struct + class variants) │
│ 5. Emit InquiryGeneratedSchema.Ddl │
│ 6. Emit InquiryGeneratedServiceRegistration │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ Compiled assembly │
│ │
│ partial class ShipperStore │
│ { │
│ private const string _sqlSelectAll = "SELECT ... FROM \"Shippers\""; │
│ public partial Task<IReadOnlyList<Shipper>> SelectAllAsync(...) │
│ => Inquiry.QueryListAsync<Shipper, ShipperStructMat>(...); │
│ } │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼ at run time
┌─────────────────────────────────────────────────────────────────────────────┐
│ InquiryRequestPipeline │
│ │
│ 1. OpenConnection (provider-specific factory) │
│ 2. CreateCommand, set CommandText = baked _sql... │
│ 3. Bind parameters via the per-method binder lambda │
│ 4. ExecuteReaderAsync(CommandBehavior.SingleResult | SequentialAccess) │
│ 5. Read each row, call struct materializer (inlined per concrete type) │
└─────────────────────────────────────────────────────────────────────────────┘
What lives where
| Project | Role |
|---|---|
Inquiry |
Public runtime: IInquiry facade, attributes, command/parameter types, transactions, options, and DI extension. The request pipeline is internal. Ships zero SQL. |
Inquiry.Generators.Shared |
The shared incremental generator framework — entity/store discovery, the per-dialect SqlBuilder hierarchy, the emitters. Bundled privately into each provider analyzer. |
Inquiry.<Dialect>.Analyzer |
The dialect's Roslyn analyzer. Wraps the shared framework with a [Generator] attribute that fires only when its dialect matches [assembly: InquiryDialect]. |
Inquiry.<Dialect> |
The runtime provider package: DI extension (AddInquirySqlite, AddInquirySqlServer, ...), provider options, internal connection factory, and the [assembly: InquiryDialect] marker. |
Why one dialect per assembly
[InquiryDialect] is AllowMultiple = false. The generator emits one set of SQL per assembly — there's no runtime dialect dispatch. To target multiple databases, split your entities across assemblies, one per dialect.
This is intentional. Inquiry's whole performance story rests on the SQL being a const string — known at compile time, never built, never adapted at runtime. Allowing multi-dialect would require runtime dispatch and would forfeit that property.
What the runtime actually does
For a SELECT (list read), the request pipeline is dramatically shorter than an ORM:
- Open a connection (pooled by the provider).
- Create a command.
- Set
CommandText = _sqlSelectAll(the const string baked at compile time). - Call the per-method binder lambda to add parameters (also generated).
ExecuteReaderAsyncwithCommandBehavior.SingleResult | SequentialAccess— stream forward-only.- Per row, call the per-call struct materializer. The JIT specializes per concrete materializer type, so the call inlines — no virtual dispatch.
- Yield or accumulate.
There is no SQL building, no expression-tree compilation, no per-call reflection. The only allocations on the read path are the entities themselves and the List<T> (when buffered).
Single-row reads pass CommandBehavior.SingleResult (and SequentialAccess for generated-store reads). They deliberately omit CommandBehavior.SingleRow — the QuerySingleOrDefaultAsync contract throws when a query returns more than one row, and that detection needs a second ReadAsync to observe the extra row. SingleRow would let providers stop after the first row and silently suppress the throw.
For inserts / updates / deletes (ExecuteNonQuery), the pipeline skips the reader entirely.
Interceptors, transactions, prepared statements
The pipeline supports the usual cross-cutting features without forfeiting compile-time SQL:
- Interceptors (
IInquiryCommandInterceptor) — observe / mutate the command before and after execution. - Transactions —
IInquiry.ExecuteInTransactionAsync(...)owns the common begin/commit/rollback flow.IInquiry.BeginTransactionAsync()remains available when callers need manual rollback or savepoints; it installs anAsyncLocalslot pointing at a transacted pipeline that reuses one connection and oneDbTransaction. Generated stores resolved from DI automatically join the open transaction via that slot; ad-hoc SQL goes through theIInquiryTransactionhandle's own forwarding methods (tx.ExecuteAsync(...), etc.). NestedBeginTransactionAsynccreates savepoints (unbounded depth). The handle's forwarding methods throwObjectDisposedExceptionafter the transaction closes. Full writeup: Transactions. - Prepared statements - enabled by default via
InquiryOptions.PrepareStatements = PreparedStatementMode.Autoand capability-gated per provider. The pipeline callsPrepareAsynconce per command only when the provider can reuse prepared state. - Retry on transient cloud errors — provider factories wrap connection opens with an exponential-backoff retry policy for known transient codes (Azure SQL, CockroachDB, Aurora, etc.).
What the generator emits, per assembly
Counting from a single Northwind sample assembly:
- One
*InquiryEntity.g.csper[InquiryTable]entity — the class + struct materializers. - One
*Store.InquiryStore.g.csper[InquiryStore<T>]partial class — the baked SQL consts and partial method bodies. - One
InquiryGeneratedSchema.g.cs— the full CREATE TABLE DDL for every entity, in dependency order, as aconst string. - One
InquiryGeneratedServiceRegistration.g.cs— the DI registration class invoked byAddInquiryGeneratedStores().
You can see these for yourself by adding <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles> to your csproj and rebuilding.
Diagnostics
When you write something the generator can't handle — an unknown column name in a [InquirySelectAllByField], a key the store doesn't have, an unsupported return shape — you get an INQxxx diagnostic at build time with the exact source location. No 3 AM debugging of a bad SQL string.
See also
- Getting started — the 5-minute walkthrough.
- Architecture deep-dive — the full pipeline and emitter internals.
- CRUD feature page — input source side-by-side with the actual generated output.