Skip to content

Read Queries

Nagare.ReadQueries is a typed query API for projection-backed read models. Two tiers: an inline interpolated-SQL fluent surface for one-line queries, and a [ReadQuery] partial-method attribute for multi-line SQL with custom result shapes, streaming, and pagination. SQL is always the SQL — no LINQ-to-SQL translation, no expression trees, paste-able into psql/sqlite3 to debug.

Choosing your approach

Four orthogonal decisions cover most projection-query work. Get the first right and the rest follow.

1. Tier 1 fluent vs Tier 2 [ReadQuery] vs raw Dapper

PickWhen
Tier 1store.Where($"…").OrderBy(…).ToList()One-line WHERE + ORDER BY against a single table, no joins. Stays inline with the projection class.
Tier 2[ReadQuery("…SQL…")] on a partial methodMulti-line SQL, joins, subqueries, aggregates, custom result shapes, streaming, pagination. SQL sits next to the method signature and pastes straight into a DB tool.
Raw Dapper_connection.QueryAsync<T>(…)Sprocs, dynamic SQL composition, anything ad-hoc. The escape hatch is always open; the typed layer doesn't fight you for skipping it.

Switch from Tier 1 to Tier 2 when: the WHERE clause grows past one line, you need a JOIN, the result shape differs from the row type, you want pagination/streaming, or you find yourself writing helper methods to compose interpolated strings. Tier 2 also gets a richer build-time check — the whole SQL body can be parsed against your declared columns, not just the OrderBy / Where arguments Tier 1 sees.

Stay on Tier 1 for: lookup-by-id, simple status filters, the kind of read that would otherwise be a 3-line Dapper call.

2. Columnar vs JSON projection vs hybrid

PickWhen
Columnar — one column per attributeStable schema; multiple fields participate in WHERE/ORDER BY; you read more than 2-3 fields per query. The 80% default.
JSON payload — single payload column (JSONB / TEXT / JSON)Variable shape across records; sparse fields; deep nesting; the projection evolves faster than column migrations are willing to ship.
Hybrid — indexed columns for the hot path + JSON for the long tailMost stabilised projections end up here. Index the 1-3 columns you actually filter on; keep the rest in JSON for evolvability.

Reach for JSON when migration friction outweighs query precision. See Scaling JSON projections for per-dialect index recipes — without an index, JSON filters scan the whole table.

3. Buffered (Task<IReadOnlyList<T>>) vs streaming (IAsyncEnumerable<T>)

PickWhen
BufferedUI lists, paged tables, anything that fits comfortably in memory. The default.
StreamingExport jobs, ETL, audit dumps, long-tail reports. Memory must not spike with table growth.

Rule of thumb: if the result set scales with table size (not page size), stream. Streaming holds the reader open for the whole enumeration — wrap consumers in await foreach and thread a CancellationToken for early-exit.

4. Offset pagination vs keyset pagination

PickWhen
Offset.Limit(n).Offset(skip) (Tier 1) or LIMIT @n OFFSET @m (Tier 2)Static datasets, internal tools, the first handful of pages. Cheap to write.
KeysetPage<T> with ToPage(size, r => [r.Id])Live data (rows arrive between page loads); deep pages; you need stable ordering across requests.

Keyset stays fast on large tables because it skips OFFSET 50000-style scans. Offset is fine while pages are shallow and the dataset isn't changing under you.

Declare the read model

csharp
using Nagare.ReadQueries;

[ReadModel("loan_status")]
public partial record LoanRow(string Id, string PatronId, string BookId, string Status);

The source generator emits static ReadModelTable Table (column list with CLR types) and static LoanRow Read(DbDataReader r) (reflection-free hydration). PascalCase parameters map to snake_case columns. Handled types: primitives, Guid, DateTime, DateTimeOffset, DateOnly, TimeOnly, byte[], enums, nullable variants.

Register at startup

csharp
builder.Services.AddSqliteReadQueries();
// or AddPostgresReadQueries / AddMySqlReadQueries / AddSqlServerReadQueries

builder.Services.AddReadQueries<LoanRow>(
    owningProjection: LoanStatusProjection.Identifier.Value);

owningProjection is optional and only affects the dashboard's "owning projection" label.

Tier 1 — inline fluent

Inject IReadQueries<LoanRow> wherever you'd put a repository; the projection itself is the usual home:

csharp
public class LoanStatusProjection
{
    private readonly IReadQueries<LoanRow> _store;

    public Task<LoanRow?> GetById(string id) =>
        _store.Where($"id = {id}").Single();

    public Task<IReadOnlyList<LoanRow>> Active() =>
        _store.Where($"status = {"Active"}").OrderBy("id").ToList();
}

Every {value} hole becomes a @p0/@p1/... parameter — strings included. SQL injection is impossible by construction.

MethodEffect
Where(handler) / All()Start a query (with WHERE clause / unfiltered)
And(handler)Append another WHERE clause, joined with AND
OrderBy(col) / OrderByDesc(col)Sort by a column name (validated by NAG1001)
Limit(n) / Offset(n)Paginate
ToList(ct)Run, return IReadOnlyList<TRow>
Single(ct)Single match or null; throws on > 1
FirstOrDefault(ct)First match or null
Count(ct)COUNT(*), returns int

Tier 2 — [ReadQuery] partial methods

For multi-line SQL, declare a partial method on a partial class and annotate with [ReadQuery("…SQL…")]. The generator emits the body; the return type drives dispatch.

csharp
public partial class LoanStatusProjection
{
    private readonly IReadQueries<LoanRow> _store;

    [ReadQuery("""
        SELECT id, patron_id, book_id, status
        FROM loan_status
        WHERE status = @status
        ORDER BY id ASC
        """)]
    public partial Task<IReadOnlyList<LoanRow>> ByStatus(string status);

    [ReadQuery("SELECT * FROM loan_status WHERE id = @id")]
    public partial Task<LoanRow?> ById(string id);

    [ReadQuery("SELECT COUNT(*) FROM loan_status WHERE status = @status")]
    public partial Task<int> CountByStatus(string status);
}

Method parameter names bind to SQL @param references case-insensitively. Optional last parameter CancellationToken ct is supported and threaded through.

Custom result shapes

When the SELECT doesn't match the row type, return a different record. The generator detects the mismatch, generates a hydrator from the projection record's primary-constructor parameters (positional, like Sqlx's query_as!), and dispatches via ExecuteProjection:

csharp
public sealed record LoanCountByStatus(string Status, int Count);

[ReadQuery("SELECT status, COUNT(*) AS count FROM loan_status GROUP BY status ORDER BY count DESC")]
public partial Task<IReadOnlyList<LoanCountByStatus>> StatusBreakdown();

The projection record needs a primary constructor — [ReadModel] is not required (it's not backing a table). Column order in the SELECT must match parameter order in the record.

Streaming with IAsyncEnumerable<T>

For large result sets, return IAsyncEnumerable<T> instead of Task<IReadOnlyList<T>>. The reader stays open for the duration of the enumeration; rows yield without buffering. Pair with CancellationToken for early-exit.

csharp
[ReadQuery("SELECT id, patron_id, book_id, status FROM loan_status ORDER BY id")]
public partial IAsyncEnumerable<LoanRow> StreamAll(CancellationToken ct = default);

Works for projection shapes too — IAsyncEnumerable<LoanCountByStatus> routes through ExecuteProjectionStream.

Keyset / cursor pagination

Page<T> carries items + a NextCursor token. The generator stays simple; you wrap a regular [ReadQuery] with PageExtensions.ToPage:

csharp
[ReadQuery("""
    SELECT id, patron_id, book_id, status
    FROM loan_status
    WHERE (@afterId IS NULL OR id > @afterId)
    ORDER BY id ASC
    LIMIT @pageSize
    """)]
private partial Task<IReadOnlyList<LoanRow>> ByIdPagedRaw(string? afterId, int pageSize);

public async Task<Page<LoanRow>> ByIdPaged(string? afterId, int pageSize)
{
    var rows = await ByIdPagedRaw(afterId, pageSize);
    return rows.ToPage(pageSize, r => [r.Id]);
}

Cursor.Encode(params object?[]) and Cursor.Decode(cursor) round-trip cursor values via base64-encoded JSON. Use Cursor.DecodeAs<T>(cursor, index) for typed extraction on the next call.

What the generator checks

The generator wires every [ReadQuery] method to the SQL and refuses to build if the wiring is off. The errors are loud on purpose — you find out at compile time, not when the projection runs in production:

  • The enclosing class has to expose an IReadQueries<TRow> (field, property, or constructor parameter) so the generator knows which table to query.
  • Every C# parameter has to appear as @name in the SQL, and every @name in the SQL has to have a matching C# parameter. Mismatches are an error either way.
  • The return type has to be one of the supported shapes (Task<IReadOnlyList<T>>, Task<T?>, IAsyncEnumerable<T>, or a scalar like Task<int>).
  • Custom result records need a primary constructor — the generator hydrates positionally from the SELECT columns.

Errors carry codes NAG3001NAG3005 if you ever need to look one up.

Catching SQL typos at build time (opt-in)

Tell each [ReadQuery] which dialect it targets and Nagare will parse every column reference at build time. Typos fail the build:

csharp
[ReadQuery("""
    SELECT id, patron_id, book_id, status
    FROM loan_status
    WHERE status = @status
    """,
    Dialect = SqlDialect.Postgres)]
public partial Task<IReadOnlyList<LoanRow>> ByStatus(string status);

Misspell a column and you get:

error NAG4001: Column 'statux' referenced in [ReadQuery] SQL on '__ByStatus_Query_0'
               is not declared on the row/projection type
               (known columns: id, patron_id, book_id, status)

Set Dialect to Postgres, Sqlite, MySql, or SqlServer — each parses with the right dialect rules (quoted identifiers, JSON operators, vendor functions). SqlDialect.None is the default and skips validation.

A few honest caveats:

  • The validator runs against SqlParserCS. If it can't parse a vendor-specific construct (PG arrays, SQL Server WITH (NOLOCK), etc.), it skips that query with a build warning — your runtime behaviour is unchanged. Set <NagareTreatReadQueryParseFailuresAsErrors>true</NagareTreatReadQueryParseFailuresAsErrors> to escalate the skip to a NAG4002 build error when you'd rather fix or explicitly exempt (Dialect = SqlDialect.None) every unparseable query than let it slide.
  • JSON path strings ('$.status', 'name') are opaque values, not column refs — the validator checks the column the JSON function reads from, not the keys inside the JSON.
  • Disable per-project via <NagareValidateReadQueriesDisable>true</NagareValidateReadQueriesDisable> if you ever need to.

Catching column typos at build time

Typos in column names fail the build, not production. Three analyzers run in your IDE and in CI:

  • OrderBy column names get checked strictly against the [ReadModel]. Misspell created_at as craeted_at and you see a build error before the test even runs:
    error NAG1001: Column 'craeted_at' is not declared on read model 'LoanRow'
                   (known columns: id, patron_id, book_id, status)
  • Columns in interpolated Where / And clauses get a best-effort scan — anything that looks like col = …, col IN …, col LIKE … is checked. This one is a warning (not error) because it can't always tell a column from a table-prefixed (t.col) or function-wrapped (LOWER(col)) reference and skips those to stay quiet. Suppress per-project with <NoWarn>$(NoWarn);NAG1002</NoWarn> if you hit a false positive.
  • [TenantScoped] projections get their INSERT and UPDATE statements scanned for the tenant column. Forget the column on a multi-tenant write and you get a warning before tenant data leaks across rows.

Querying JSON document projections

Sometimes a projection is more naturally a JSON document than a fixed set of columns — when the shape varies per record, or you'd rather add fields without a schema migration. Nagare projections already support this on every dialect (Postgres / MySQL JSONB, SQLite json1, SQL Server JSON). To query one, declare a [ReadModel] with a payload column and write the JSON operator in the query body:

csharp
[ReadModel("loan_documents")]
public partial record LoanDocumentRow(string Id, string Payload);

public partial class LoanDocumentQueries(IReadQueries<LoanDocumentRow> store)
{
    // Postgres: JSON arrow operators in WHERE
    [ReadQuery("""
        SELECT id, payload
        FROM loan_documents
        WHERE payload @> '{"status":"active"}'
        """, Dialect = SqlDialect.Postgres)]
    public partial Task<IReadOnlyList<LoanDocumentRow>> ActivePg();

    // SQLite: json_extract function (json1 extension, default in modern SQLite)
    [ReadQuery("""
        SELECT id, payload
        FROM loan_documents
        WHERE json_extract(payload, '$.status') = 'active'
        """, Dialect = SqlDialect.Sqlite)]
    public partial Task<IReadOnlyList<LoanDocumentRow>> ActiveSqlite();

    // Project JSON fields into a typed shape (custom projection)
    [ReadQuery("""
        SELECT id, json_extract(payload, '$.patron_id') AS patron_id, json_extract(payload, '$.amount') AS amount
        FROM loan_documents
        """, Dialect = SqlDialect.Sqlite)]
    public partial Task<IReadOnlyList<LoanSummary>> Summarize();
}

public sealed record LoanSummary(string Id, string PatronId, decimal Amount);

What the validator handles

ConstructStatusNotes
PG payload->'name' (JSON arrow → JSONB)✅ ValidatedThe column ref is checked
PG payload @> '{...}' (containment)✅ Validated
PG payload @? '$.path' (jsonb_path_exists)✅ Validated
PG jsonb_extract_path_text(payload, 'name')✅ ValidatedRecommended over ->> (below)
PG payload->>'name' (JSON arrow → text, with string literal)✅ ValidatedSqlParserCS 0.6.x's tokenizer trips on this — the validator preprocesses Postgres SQL to rewrite col->>'literal' to jsonb_extract_path_text(col, 'literal') before parsing. Same semantics, column ref still checked. Runtime executes the original SQL unchanged.
SQLite json_extract(payload, '$.path')✅ ValidatedThe default json1 extension ships with modern SQLite
MySQL JSON_EXTRACT(payload, '$.path')✅ Validated
MySQL payload->'$.path' (JSON arrow)✅ Validated
SQL Server JSON_VALUE(payload, '$.path')✅ Validated

Column refs inside any of these (e.g. JSON_VALUE(typo_col, '$.x')) are caught — the validator walks into function args.

The RelationalDocumentStoreProjection base class (under Nagare.Subscriptions.Projections) is the canonical way to write the JSON column from events. The query side composes naturally — they don't need to know about each other. The decision of when to reach for JSON vs columnar lives up in Choosing your approach.

Scaling JSON projections — indexing strategy

Without an index, every WHERE payload->>'status' = ... (PG) or WHERE JSON_EXTRACT(payload, '$.status') = ... (MySQL) scans the whole table. The validator catches column typos; it doesn't catch missing indexes. Per-dialect recipes:

PostgreSQL — three index choices, pick by query shape:

QueryIndexWhy
WHERE payload->>'status' = 'active' (one hot path)CREATE INDEX … ON t ((payload->>'status')) — B-tree on the extracted textSmallest, supports range/ORDER BY, exact match on hot key
WHERE payload @> '{"status":"active"}' (containment, varied keys)CREATE INDEX … ON t USING gin (payload jsonb_path_ops)Smaller GIN, containment-only, faster than default jsonb_ops
WHERE payload ? 'tag' OR payload @> {...} (any-key, multiple operators)CREATE INDEX … ON t USING gin (payload) — default jsonb_opsBigger index, more operator coverage

GIN has noticeable write overhead — match the operator class to the queries you actually run. Pair with partial indexes when only a fraction of rows match (e.g. WHERE archived_at IS NULL).

MySQL — generated column + B-tree:

JSON columns can't be indexed directly. Pull the hot path into a VIRTUAL generated column and index that:

sql
ALTER TABLE book_documents
    ADD COLUMN status_v VARCHAR(64) AS
    (JSON_UNQUOTE(JSON_EXTRACT(payload, '$.status'))) VIRTUAL;
CREATE INDEX idx_book_status ON book_documents (status_v);

The optimizer uses the index only when the WHERE clause references the generated column, not the raw JSON path. So queries become WHERE status_v = 'active', not WHERE JSON_EXTRACT(payload, '$.status') = .... The [ReadModel] declaration includes the generated column the same as any other; the validator checks it like a normal column. For arrays, look at MySQL 8.0.17's multi-valued indexes (CAST(... AS UNSIGNED ARRAY)).

SQLite — expression index via json1:

sql
CREATE INDEX idx_book_author ON book_documents (json_extract(payload, '$.author'));

Works natively, no virtual column dance. The Library sample's BookDocumentProjection demonstrates this.

Verify with EXPLAIN — every dialect's plan should show Index Scan using idx_… (PG) / using index_name (MySQL) / SEARCH not SCAN (SQLite). The dashboard's EXPLAIN button surfaces the plan with one click; if it shows a seq scan on your hot query, you're missing the index.

Multi-tenant read queries

Mark a read model as tenant-scoped and reads stop leaking rows across tenants. The wiring differs per tier — Tier 1 filters at render time, Tier 2 bakes the predicate into the SQL at generation time, Tier 3 (raw Dapper) is yours to scope by hand. The Library sample's BookCatalogProjection shows all three tiers side-by-side.

Tier 1 — TenantFilter (fluent)

Register a TenantFilter and the runtime auto-appends AND tenant_id = @tenant_id (or your configured column) to every Tier 1 query — pulled from ITenantContext.TenantId at execution time.

csharp
// 1. Register a tenant context (per-request, scoped).
builder.Services.AddScoped<ITenantContext, HttpTenantContext>();

// 2. Configure the read model with the filter.
builder.Services.AddReadQueries<LoanRow>(
    owningProjection: LoanStatusProjection.Identifier.Value,
    tenantFilter: new TenantFilter(Column: "tenant_id"));

// 3. Queries auto-scope. No code change at the call site.
public Task<IReadOnlyList<LoanRow>> Active() =>
    _store.Where($"status = {"Active"}").OrderBy("id").ToList();
// Rendered: SELECT … WHERE status = @p0 AND tenant_id = @tenant_id ORDER BY "id"

When ITenantContext.TenantId is null:

  • Default — the read throws with a clear error. A missing tenant for a scoped read is almost always a bug.
  • OverrideTenantFilter(ThrowWhenTenantMissing: false) allows unscoped reads (system jobs, replay workloads, public paths). This knob applies to Tier 1 only — see below.

Tier 2 — [ReadQuery(TenantScoped = true)]

Opt in per method and the generator splices {TenantColumn} = @{TenantColumn} into the SQL at generation time — appended as WHERE (or AND when you already have one), placed before any trailing GROUP BY / HAVING / ORDER BY / LIMIT, or wrapped as a derived table (SELECT * FROM (…) AS nagare_tenant_scope WHERE …) when the statement has a top-level UNION / EXCEPT / INTERSECT. The runtime binds the parameter from ITenantContext.TenantId at execution time; ITenantContext is resolved from DI whether or not a TenantFilter is registered.

csharp
[ReadQuery("""
    SELECT id, patron_id, book_id, status
    FROM loan_status
    WHERE status = @status
    """, TenantScoped = true, Dialect = SqlDialect.Sqlite)]
public partial Task<IReadOnlyList<LoanRow>> ByStatus(string status);
// Effective SQL: … WHERE status = @status AND tenant_id = @tenant_id

Because the predicate is baked into the SQL, a Tier-2 scoped query always requires a tenant in context — a null ITenantContext.TenantId throws, and ThrowWhenTenantMissing = false does not relax it (there is no unscoped SQL to fall back to).

Custom column. Set TenantColumn when your table names the tenant column differently. The same name drives the injected predicate, the bound parameter, and the NAG3006 mention check:

csharp
[ReadQuery("SELECT * FROM accounts", TenantScoped = true, TenantColumn = "account_id")]
public partial Task<IReadOnlyList<AccountRow>> All();

Manual wiring. If the SQL already references the tenant column, the generator leaves the text untouched and only binds @{TenantColumn} from the context — write the predicate yourself, keep TenantScoped = true, and don't declare a method parameter for it (NAG3003 exempts the tenant parameter on scoped methods):

csharp
[ReadQuery("""
    SELECT id, patron_id, book_id, status
    FROM loan_status
    WHERE tenant_id = @tenant_id AND status = @status
    """, TenantScoped = true)]
public partial Task<IReadOnlyList<LoanRow>> ByStatus(string status);

The generator emits info-level NAG3007 for this shape — a heads-up that no injection happened. The opt-in is still what makes the runtime bind @tenant_id, so don't remove it.

Caveat — sub-queries. The "already references the column" scan is lexical, not parsed: a tenant reference inside a sub-query (WHERE id IN (SELECT … WHERE tenant_id = @t)) counts as top-level tenant awareness and suppresses injection (NAG3006 shares this blind spot). If the outer query needs its own predicate, write it explicitly.

Tier 3 — raw Dapper

No scoping help — include the tenant predicate in your SQL and pass the value yourself. The escape hatch trades the guardrails for control.

The NAG3006 tripwire

Mark the projection class [TenantScoped] (the write-side attribute from Nagare.Subscriptions, works on classes and records) and every [ReadQuery] method on it that is neither TenantScoped = true nor references the tenant column gets a NAG3006 warning — the read may leak rows across tenants. Like NAG1003 it's a tripwire over brittle SQL matching, not a proof; suppress with #pragma warning disable NAG3006 when a read is intentionally global.

How the auto-stamp connects

Registering ITenantContext in DI also wires the command side: Aggregate.Ask reads from the context and auto-stamps EventMetadata.TenantId on every emitted event, the same way CommandSource and CommandType already auto-populate. You don't pass tenant through every command handler — it rides on the metadata once you set the context at the request boundary (HTTP middleware, message-pump entry, scheduler).

csharp
// Request boundary (HTTP middleware or similar):
public sealed class HttpTenantContext(IHttpContextAccessor http) : ITenantContext
{
    public string? TenantId => http.HttpContext?.Items["tenant_id"] as string;
}

builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ITenantContext, HttpTenantContext>();

// Command handler — no tenant code needed:
await repo.Ask(loanId, new RequestLoan(patron, book));
// → event.Metadata.TenantId is auto-stamped from HttpTenantContext.

// Projection — implement ITenanted on your state, framework auto-copies the value:
public sealed class LoanState : IJsonable, IProjectionState, ITenanted
{
    public string? TenantId { get; set; }   // framework writes this from envelope.Metadata
    // ... your other fields
}

// Read store — auto-scoped (above).

The ITenanted interface lives in Nagare.Store. When RelationalDocumentStoreProjection upserts a state that implements it, the framework copies envelope.Metadata.TenantId into the state before persisting. Explicit values set in your Apply handler are preserved — the auto-fill never overwrites a non-null tenant.

For columnar projections that don't inherit RelationalDocumentStoreProjection, copy envelope.Metadata?.TenantId into your INSERT manually and mark the class [TenantScoped] — the NAG1003 analyzer scans every INSERT/UPDATE in the class and warns if the tenant column is missing.

Transaction-aware reads

store.WithTransaction(tx) returns a view bound to an open DbTransaction. Queries on the view run on the transaction's connection with cmd.Transaction = tx set — so they see uncommitted writes from the same workflow. The connection is borrowed; disposing the view doesn't close the caller's transaction.

csharp
await using var conn = factory();
await conn.OpenAsync(ct);
await using var tx = await conn.BeginTransactionAsync(ct);

await conn.ExecuteAsync(
    "INSERT INTO loan_status (id, ...) VALUES (...)", new { ... }, tx);

var txStore = _store.WithTransaction(tx);
var loan = await txStore.Where($"id = {newId}").Single();   // sees the insert

await tx.CommitAsync(ct);

Tenant scoping is preserved through WithTransaction automatically. Use this for read-your-own-write inside a single workflow. Cross-request consistency after a command is a different problem — it belongs in command-dispatch / projection-checkpoint layers, not here. See eventual consistency UX patterns.

Read-replica routing

By default the read queries reuses the same DbConnection factory the write side uses. To route reads at a separate host (Azure SQL ApplicationIntent=ReadOnly, a Postgres hot standby, or a dedicated replica entirely), pass a connection factory:

csharp
builder.Services.AddReadQueries<LoanRow>(
    connectionFactory: sp =>
    {
        var cfg = sp.GetRequiredService<IConfiguration>();
        return new NpgsqlConnection(cfg.GetConnectionString("ReadReplica"));
    },
    owningProjection: LoanStatusProjection.Identifier.Value);

Each query opens a fresh connection from the factory — keep it cheap, ideally with a pool behind it. The write side keeps using whatever DbConnection is registered via AddNagare{Provider}Storage. There's no read-after-write guarantee against a replica with replication lag, so don't route queries you issue immediately after a command — those should still hit the primary.

Dialect support

ProviderIdentifier quotingPagination
AddPostgresReadQueries"name" (ANSI)LIMIT n OFFSET m
AddSqliteReadQueries"name" (ANSI)LIMIT n OFFSET m
AddMySqlReadQueries`name`LIMIT n OFFSET m
AddSqlServerReadQueries[name]OFFSET n ROWS FETCH NEXT m ROWS ONLY (implicit ORDER BY (SELECT NULL) when no order specified)

All four implement IQueryDialect. Override per-deployment by registering your own before the provider extension; the registration uses TryAddSingleton.

Dashboard

The dashboard auto-discovers every [ReadModel] registered via AddReadQueries<T>():

  • Read Models tab — list of registered models, owning projection, column count
  • Schema view — per model, columns with CLR types and nullable flags
  • Query log — last 100 dispatches per model: SQL, parameters, duration, row count
  • EXPLAIN button — re-runs any logged query as EXPLAIN (or provider equivalent — SET SHOWPLAN_ALL on SQL Server)

Endpoints (gated by INagareDashboardAuthorizationFilter with Browse):

GET  /_nagare/api/read-models
GET  /_nagare/api/read-models/{name}
GET  /_nagare/api/read-models/{name}/queries?limit=50
POST /_nagare/api/read-models/{name}/queries/{id}/explain

See also

  • samples/Nagare.Samples.LibraryLoanStatusProjection exercises inline + multi-line + custom projection + streaming + pagination; BookDocumentProjection shows the JSON document path with json_extract queries and an expression index in Prepare().
  • Projections & Subscriptions — the write side.
  • Dashboard — the operational surface that consumes the read-queries registry.

流れ — flow.