RoomSharp.QueryExtensions 0.1.0

dotnet add package RoomSharp.QueryExtensions --version 0.1.0
                    
NuGet\Install-Package RoomSharp.QueryExtensions -Version 0.1.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="RoomSharp.QueryExtensions" Version="0.1.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="RoomSharp.QueryExtensions" Version="0.1.0" />
                    
Directory.Packages.props
<PackageReference Include="RoomSharp.QueryExtensions" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add RoomSharp.QueryExtensions --version 0.1.0
                    
#r "nuget: RoomSharp.QueryExtensions, 0.1.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package RoomSharp.QueryExtensions@0.1.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=RoomSharp.QueryExtensions&version=0.1.0
                    
Install as a Cake Addin
#tool nuget:?package=RoomSharp.QueryExtensions&version=0.1.0
                    
Install as a Cake Tool

RoomSharp.QueryExtensions

Designed for developers who want control, not magic.

RoomSharp.QueryExtensions is a small companion package to RoomSharp that adds a Dapper-like API on top of RoomSharp.Core.RoomDatabase for ad-hoc SQL:

  • Query POCOs/DTOs/records without declaring a DAO method
  • Fluent Query Builder - Laravel-style db.From<T>().Where().OrderBy().Get()
  • Eager Loading - Include related entities with batched queries
  • Stream results with IAsyncEnumerable<T>
  • Run raw commands and scalars (Execute*, ExecuteScalar*)
  • Multi-mapping up to 5 types with splitOn
  • Drop down to low-level, allocation-free row access (RowReader)

It keeps the call-site ergonomic, while caching the expensive parts (parameter binding and result materialization) behind the scenes.

Note: This is a feature-complete preview. APIs may evolve, but the overall design is considered stable.

⚠️ QueryExtensions is not a full ORM. It focuses on querying and mapping, not change tracking or migrations.

When Should I Use QueryExtensions?

Use QueryExtensions when:

  • You need ad-hoc queries without creating a DAO
  • You want Dapper-like control with RoomSharp integration
  • You need streaming or zero-allocation access

Prefer DAOs when:

  • Queries are part of your core domain
  • You want compile-time SQL ownership
  • You rely on migrations and schema coupling

Install

dotnet add package RoomSharp.QueryExtensions

Target frameworks: net8.0, net9.0, net10.0.

Quick Examples

// Fluent query (type-safe)
var users = await db.From<User>()
    .Where(u => u.IsActive)
    .OrderBy(u => u.Name)
    .GetAsync();

// With eager loading
var orders = await db.From<Order>()
    .Include(o => o.Customer)
    .GetAsync();

// Raw SQL
var count = await db.ExecuteScalarAsync<long>(
    "SELECT COUNT(*) FROM users WHERE IsActive = @active",
    new { active = true });

// Streaming large results
await foreach (var item in db.QueryStreamAsync<Item>("SELECT * FROM items"))
{
    Process(item);
}

Fluent Query Builder

A Laravel-style fluent API for building queries without writing raw SQL. Supports both String-based and Expression-based syntax.

Basic Usage

using RoomSharp.QueryExtensions.FluentQuery;

// String-based
var todos = await db.Table<Todo>("todos")
    .Where("IsDone", false)
    .Where("CategoryId", ">", 5)
    .OrderBy("CreatedAt")
    .Take(20)
    .GetAsync();

// Expression-based (Type-safe)
var todos = await db.From<Todo>()
    .Where(t => t.IsDone == false)
    .Where(t => t.CategoryId > 5)
    .OrderBy(t => t.CreatedAt)
    .Take(20)
    .GetAsync();

Where Methods

Method Example
Where(col, val) .Where("IsActive", true)
Where(col, op, val) .Where("Age", ">=", 18)
OrWhere(col, val) .OrWhere("Status", "pending")
WhereIn(col, vals) .WhereIn("Status", "active", "pending")
WhereNotIn(col, vals) .WhereNotIn("Status", "deleted")
WhereNull(col) .WhereNull("DeletedAt")
WhereNotNull(col) .WhereNotNull("Email")
WhereBetween(col, a, b) .WhereBetween("Price", 10, 100)
WhereContains(col, val) .WhereContains("Title", "test")
WhereStartsWith(col, val) .WhereStartsWith("Name", "A")
WhereRaw(sql, params) .WhereRaw("LOWER(name) = @p0", "test")

Expression-based Where

// Comparison operators
.Where(t => t.Price > 100)
.Where(t => t.Status == "active")

// String methods
.Where(t => t.Title.Contains("test"))
.Where(t => t.Name.StartsWith("A"))

// Boolean properties
.Where(t => t.IsActive)
.Where(t => !t.IsDeleted)

// Method calls are supported (evaluated at runtime)
.Where(t => t.CreatedAt > DateTime.Now.AddDays(-7))
.Where(t => t.ExpiresAt < DateTime.UtcNow)

Expression-based Helper Methods

All helper methods also support type-safe Expression syntax:

Method Example
WhereNull(x => x.Prop) .WhereNull(x => x.DeletedAt)
WhereNotNull(x => x.Prop) .WhereNotNull(x => x.Email)
WhereIn(x => x.Prop, vals) .WhereIn(x => x.Status, "active", "pending")
WhereIn(x => x.Prop, list) .WhereIn(x => x.Id, userIds)
WhereNotIn(x => x.Prop, vals) .WhereNotIn(x => x.Type, "deleted", "spam")
WhereBetween(x => x.Prop, a, b) .WhereBetween(x => x.Price, 10m, 100m)
WhereLike(x => x.Prop, pattern) .WhereLike(x => x.Name, "A%")
WhereContains(x => x.Prop, val) .WhereContains(x => x.Description, "keyword")
WhereStartsWith(x => x.Prop, val) .WhereStartsWith(x => x.Code, "PRD")
WhereEndsWith(x => x.Prop, val) .WhereEndsWith(x => x.Email, "@gmail.com")
// Example: Type-safe query with Expression helpers
var orders = await db.From<Order>()
    .WhereNotNull(o => o.ShippedAt)
    .WhereBetween(o => o.Total, 100m, 1000m)
    .WhereIn(o => o.Status, "completed", "shipped")
    .OrderByDescending(o => o.CreatedAt)
    .GetAsync();

⚠️ Limitation: Only direct properties are supported in Expression-based methods. Nested navigation properties (e.g., x => x.Customer.Name) are NOT supported. Use Join() with string-based methods for filtering on related entities:

// ❌ NOT supported
.WhereEndsWith(x => x.Customer.Name, "Smith")

// ✅ Use Join instead
.Join("customers", "orders.customer_id", "customers.id")
.WhereEndsWith("customers.name", "Smith")

Ordering & Pagination

.OrderBy("CreatedAt")
.OrderByDescending("Priority")
.OrderBy(t => t.Name)
.Skip(20)
.Take(10)
.ForPage(3, 10)  // Page 3, 10 items per page

Terminal Methods

Method Description
Get() Returns IEnumerable<T>
GetAsync() Returns List<T> async
GetStreamAsync() Returns IAsyncEnumerable<T> (streaming)
First() / FirstAsync() First row or throws
FirstOrDefault() / FirstOrDefaultAsync() First row or null
Count() / CountAsync() Row count
Exists() / ExistsAsync() Any rows exist?

Aggregations

// String-based
var total = db.Table<Order>("orders").Sum<decimal>("Amount");
var avg = db.Table<Product>("products").Avg<double>("Price");
var max = db.Table<Order>("orders").Max<DateTime>("CreatedAt");
var min = db.Table<Product>("products").Min<decimal>("Price");

// Expression-based (type-safe)
var total = db.From<Order>().Sum(o => o.Amount);
var avg = db.From<Product>().Avg(p => p.Price);
var max = db.From<Order>().Max(o => o.CreatedAt);
var min = db.From<Product>().Min(p => p.Price);

// Async versions
var totalAsync = await db.From<Order>().SumAsync(o => o.Amount);
var maxAsync = await db.From<Order>().MaxAsync(o => o.CreatedAt);

Debugging & Configuration

// Get generated SQL without executing (for debugging/logging)
var sql = db.From<Order>()
    .Where(o => o.Status == "active")
    .ToSql();
// "SELECT * FROM orders WHERE Status = @p0"

// Get SQL with parameters
var (sql, parameters) = query.ToSqlWithParameters();

// Set command timeout
var orders = await db.From<Order>()
    .WithTimeout(TimeSpan.FromSeconds(60))
    .GetAsync();

// Or with seconds directly
db.From<Order>().WithTimeout(120).Get();

Select Projection

// Select specific columns using expression (type-safe)
var orders = db.From<Order>()
    .Select(o => new { o.Id, o.Total, o.Status })
    .Get();

// Generates: SELECT Id, Total, Status FROM orders

Multi-Provider Support

Auto-detects dialect from RoomDatabase.Dialect or DbSession.Dialect, or specify explicitly:

using RoomSharp.QueryExtensions.FluentQuery.Dialects;

db.Table<Todo>("todos", SqliteDialect.Instance)
db.Table<Todo>("todos", MySqlDialect.Instance)
db.Table<Todo>("todos", PostgreSqlDialect.Instance)
db.Table<Todo>("todos", SqlServerDialect.Instance)

Joins

// INNER JOIN
var results = db.Table<OrderDto>("orders")
    .Join("customers", "orders.CustomerId", "customers.Id")
    .Select("orders.*", "customers.Name as CustomerName")
    .GetAsync();

// LEFT JOIN
db.Table<UserDto>("users")
    .LeftJoin("addresses", "users.AddressId", "addresses.Id")
    .Select("users.*", "addresses.City")
    .Get();

// Multiple JOINs
db.Table<OrderDto>("orders")
    .Join("customers", "orders.CustomerId", "customers.Id")
    .LeftJoin("products", "orders.ProductId", "products.Id")
    .Select("orders.Id", "customers.Name", "products.Title")
    .Get();

// With operator
db.Table<T>("a").Join("b", "a.Col", ">", "b.Col");  // a.Col > b.Col

// CROSS JOIN
db.Table<T>("a").CrossJoin("b");
Method SQL Output
.Join(table, col1, col2) INNER JOIN table ON col1 = col2
.Join(table, col1, op, col2) INNER JOIN table ON col1 op col2
.LeftJoin(...) LEFT JOIN ...
.RightJoin(...) RIGHT JOIN ...
.CrossJoin(table) CROSS JOIN table

⚠️ Column Ambiguity: When using Join(), columns with the same name in multiple tables (e.g., Id) will cause SQL errors. Always use table prefix in Select():

// ✅ Correct - use table prefix to avoid ambiguity
db.From<Order>()
    .Select("orders.id", "orders.total")
    .Join("customers", "orders.customer_id", "customers.id")
    .Get();

// ❌ Wrong - "ambiguous column name: Id" error
db.From<Order>()
    .Select(o => new { o.Id, o.Total })
    .Join("customers", ...)
    .Get();

Include (Eager Loading)

Load related entities efficiently with batched queries (solves N+1 problem):

// Expression-based (type-safe, recommended)
var orders = await db.From<Order>()
    .Include(o => o.Customer)
    .Include(o => o.Items)
    .Where(o => o.Status == "active")
    .GetAsync();

// String-based (fallback)
var orders = await db.From<Order>()
    .Include("customer")
    .GetAsync();

// Nested includes (string path)
var orders = await db.From<Order>()
    .Include("customer.address")
    .GetAsync();

// Nested includes (ThenInclude - type-safe)
var orders = await db.From<Order>()
    .Include(o => o.Customer)
    .ThenInclude<Customer, Address>(c => c.Address)
    .GetAsync();

// Also works with DbSession
await using var session = await db.OpenSessionAsync();
var items = await session.From<Order>()
    .Include(o => o.Customer)
    .GetAsync();

Note: Related properties must have [Relation] attribute defined.

// One-to-One / One-to-Many
public class Order
{
    public int Id { get; set; }
    public int CustomerId { get; set; }
    
    [Relation(Entity = typeof(Customer), 
              ParentColumn = nameof(CustomerId), 
              EntityColumn = nameof(Customer.Id))]
    public Customer? Customer { get; set; }
    
    [Relation(Entity = typeof(OrderItem), 
              ParentColumn = nameof(Id), 
              EntityColumn = "OrderId")]
    public List<OrderItem>? Items { get; set; }
}

// Many-to-Many (with junction table)
public class Student
{
    public int Id { get; set; }
    
    [Relation(Entity = typeof(Course), 
              ParentColumn = nameof(Id), 
              EntityColumn = nameof(Course.Id),
              AssociateBy = "student_courses")]  // Junction table
    public List<Course>? Courses { get; set; }
}

Note: Include executes additional batched queries. It does not generate JOINs or a single large SQL statement.


API Reference

All methods are available as extension methods on both RoomDatabase and DbSession.

Query Methods

Query<T> - Returns all rows as IEnumerable<T>
// Basic usage
var todos = db.Query<Todo>("SELECT * FROM todos").AsList();

// With parameters
var activeTodos = db.Query<Todo>(
    "SELECT * FROM todos WHERE IsDone = @isDone AND CategoryId = @categoryId",
    new { isDone = false, categoryId = 5 }).AsList();

// With DbSession
await using var session = await db.OpenSessionAsync();
var todos = session.Query<Todo>("SELECT * FROM todos").AsList();
QueryAsync<T> - Returns all rows as List<T>
// Basic usage
var todos = await db.QueryAsync<Todo>("SELECT * FROM todos");

// With parameters
var activeTodos = await db.QueryAsync<Todo>(
    "SELECT * FROM todos WHERE IsDone = @isDone",
    new { isDone = false });

// With cancellation
var todos = await db.QueryAsync<Todo>(
    "SELECT * FROM todos",
    param: null,
    cancellationToken: cts.Token);
QueryStreamAsync<T> - Streams rows as IAsyncEnumerable<T>
// Stream large result sets without loading all into memory
await foreach (var todo in db.QueryStreamAsync<Todo>("SELECT * FROM todos"))
{
    Console.WriteLine(todo.Title);
}

// With parameters
await foreach (var todo in db.QueryStreamAsync<Todo>(
    "SELECT * FROM todos WHERE CategoryId = @categoryId",
    new { categoryId = 5 }))
{
    await ProcessTodoAsync(todo);
}

Single-Row Methods

QueryFirst<T> - Returns first row, throws if empty
// Throws InvalidOperationException if no rows
var todo = db.QueryFirst<Todo>(
    "SELECT * FROM todos WHERE Id = @id",
    new { id = 1 });
QueryFirstAsync<T> - Async version of QueryFirst
var todo = await db.QueryFirstAsync<Todo>(
    "SELECT * FROM todos WHERE Id = @id",
    new { id = 1 });
QueryFirstOrDefault<T> - Returns first row or default
// Returns null/default if no rows
var todo = db.QueryFirstOrDefault<Todo>(
    "SELECT * FROM todos WHERE Id = @id",
    new { id = 999 });

if (todo is null)
    Console.WriteLine("Todo not found");
QueryFirstOrDefaultAsync<T> - Async version of QueryFirstOrDefault
var todo = await db.QueryFirstOrDefaultAsync<Todo>(
    "SELECT * FROM todos WHERE Email = @email",
    new { email = "user@example.com" });
QuerySingle<T> - Returns exactly one row, throws otherwise
// Throws if 0 or 2+ rows
var user = db.QuerySingle<User>(
    "SELECT * FROM users WHERE Email = @email",
    new { email = "admin@example.com" });
QuerySingleAsync<T> - Async version of QuerySingle
var user = await db.QuerySingleAsync<User>(
    "SELECT * FROM users WHERE Id = @id",
    new { id = 1 });
QuerySingleOrDefault<T> - Returns one row or default, throws if 2+
// Returns null/default if no rows, throws if 2+ rows
var user = db.QuerySingleOrDefault<User>(
    "SELECT * FROM users WHERE Email = @email",
    new { email = "unique@example.com" });
QuerySingleOrDefaultAsync<T> - Async version of QuerySingleOrDefault
var user = await db.QuerySingleOrDefaultAsync<User>(
    "SELECT * FROM users WHERE Email = @email",
    new { email = "unique@example.com" });

Execute Methods

Execute - Executes non-query, returns affected rows
// INSERT
var inserted = db.Execute(
    "INSERT INTO todos (Title, IsDone) VALUES (@title, @isDone)",
    new { title = "New Todo", isDone = false });

// UPDATE
var updated = db.Execute(
    "UPDATE todos SET IsDone = @isDone WHERE Id = @id",
    new { isDone = true, id = 5 });

// DELETE
var deleted = db.Execute(
    "DELETE FROM todos WHERE IsDone = @isDone",
    new { isDone = true });
ExecuteAsync - Async version of Execute
var affected = await db.ExecuteAsync(
    "UPDATE todos SET Title = @title WHERE Id = @id",
    new { title = "Updated Title", id = 1 });

// With cancellation
var affected = await db.ExecuteAsync(
    "DELETE FROM todos WHERE CreatedAt < @date",
    new { date = DateTime.UtcNow.AddDays(-30) },
    cancellationToken: cts.Token);

Scalar Methods

ExecuteScalar<T> - Returns single scalar value
// COUNT
var count = db.ExecuteScalar<long>("SELECT COUNT(*) FROM todos");

// SUM
var total = db.ExecuteScalar<decimal>(
    "SELECT SUM(Amount) FROM orders WHERE UserId = @userId",
    new { userId = 1 });

// Single value
var title = db.ExecuteScalar<string>(
    "SELECT Title FROM todos WHERE Id = @id",
    new { id = 1 });

// Check existence
var exists = db.ExecuteScalar<long>(
    "SELECT COUNT(*) FROM users WHERE Email = @email",
    new { email = "test@example.com" }) > 0;
ExecuteScalarAsync<T> - Async version of ExecuteScalar
var count = await db.ExecuteScalarAsync<long>("SELECT COUNT(*) FROM todos");

var maxId = await db.ExecuteScalarAsync<long?>("SELECT MAX(Id) FROM todos");

Multi-Mapping Methods

Supports mapping 2 to 5 objects per row. The splitOn parameter specifies where each object starts:

  • For 2 types: splitOn: "col" (single column)
  • For 3+ types: splitOn: "col1,col2,..." (comma-separated)
Query<T1, T2, TResult> - Maps two objects per row
public record Order(long Id, decimal Total, long CustomerId);
public record Customer(long Id, string Name);

var orders = db.Query<Order, Customer, (Order, Customer)>(
    """
    SELECT o.Id, o.Total, o.CustomerId,
           c.Id AS CustId, c.Name
    FROM orders o
    JOIN customers c ON c.Id = o.CustomerId
    """,
    map: (order, customer) => (order, customer),
    splitOn: "CustId").AsList();
Query<T1, T2, T3, TResult> - Maps three objects per row
public record Order(long Id, decimal Total);
public record Customer(long Id, string Name);
public record Address(long Id, string City);

var rows = db.Query<Order, Customer, Address, (Order, Customer, Address)>(
    """
    SELECT o.Id, o.Total,
           c.Id AS CustId, c.Name,
           a.Id AS AddrId, a.City
    FROM orders o
    JOIN customers c ON c.Id = o.CustomerId
    JOIN addresses a ON a.Id = c.AddressId
    """,
    map: (o, c, a) => (o, c, a),
    splitOn: "CustId,AddrId").AsList();
Query<T1, T2, T3, T4, T5, TResult> - Maps up to 5 objects
var rows = db.Query<T1, T2, T3, T4, T5, TResult>(
    sql,
    map: (t1, t2, t3, t4, t5) => new Result(t1, t2, t3, t4, t5),
    splitOn: "Col2,Col3,Col4,Col5");

Important Multi-Mapping Notes:

  • splitOn values must be explicit column aliases (e.g., CustId, not Id)
  • Columns must be ordered in the SELECT to match the type parameters (T1, T2, T3...)
  • Each splitOn column marks where the next type begins

Transaction Methods

BeginTransaction - Starts a new transaction
using var tx = db.BeginTransaction();
try
{
    db.Execute("INSERT INTO logs (Message) VALUES (@msg)", new { msg = "Start" });
    db.Execute("UPDATE counters SET Value = Value + 1");
    tx.Commit();
}
catch
{
    tx.Rollback();
    throw;
}
BeginTransactionAsync - Async version of BeginTransaction
await using var tx = await db.BeginTransactionAsync();
// ...
InTransaction - Runs action in transaction with auto commit/rollback
// With return value
var result = db.InTransaction(tx =>
{
    db.Execute("INSERT INTO todos (Title) VALUES (@title)", new { title = "New" });
    return db.ExecuteScalar<long>("SELECT last_insert_rowid()");
});

// Without return value
db.InTransaction(tx =>
{
    db.Execute("UPDATE todos SET IsDone = 1 WHERE Id = @id", new { id = 1 });
    db.Execute("INSERT INTO logs (Message) VALUES (@msg)", new { msg = "Completed" });
});
InTransactionAsync - Async version of InTransaction
var newId = await db.InTransactionAsync(async (tx, ct) =>
{
    await db.ExecuteAsync("INSERT INTO todos (Title) VALUES (@title)", new { title = "New" });
    return await db.ExecuteScalarAsync<long>("SELECT last_insert_rowid()");
});

// Without return value
await db.InTransactionAsync(async (tx, ct) =>
{
    await db.ExecuteAsync("DELETE FROM todos WHERE IsDone = 1");
    await db.ExecuteAsync("VACUUM");
});

DbSession Methods

All query/execute methods are also available on DbSession for Parallel mode:

// Open session
await using var session = await db.OpenSessionAsync();

// Query methods
var todos = await session.QueryAsync<Todo>("SELECT * FROM todos");
var todo = await session.QueryFirstOrDefaultAsync<Todo>("SELECT * FROM todos WHERE Id = @id", new { id = 1 });

// Execute methods
await session.ExecuteAsync("UPDATE todos SET Title = @title WHERE Id = @id", new { title = "New", id = 1 });

// Scalar methods
var count = await session.ExecuteScalarAsync<long>("SELECT COUNT(*) FROM todos");

// Multi-mapping
var rows = session.Query<Order, Customer, (Order, Customer)>(sql, (o, c) => (o, c), "CustomerId").AsList();

// Transactions
await session.InTransactionAsync(async (tx, ct) =>
{
    await session.ExecuteAsync("INSERT INTO todos (Title) VALUES (@title)", new { title = "A" });
    await session.ExecuteAsync("INSERT INTO todos (Title) VALUES (@title)", new { title = "B" });
});

Helper Methods

AsList - Materializes IEnumerable to List
var list = db.Query<Todo>("SELECT * FROM todos").AsList();
ToListAsync - Async materialization
var list = await db.QueryStreamAsync<Todo>("SELECT * FROM todos").ToListAsync();

Low-Level Row Access (RowReader)

For zero-allocation row processing, use ReadRaw / ReadRawAsync with RowReader:

using RoomSharp.QueryExtensions.Extensions;
using RoomSharp.QueryExtensions.LowLevel;

// Async version
await db.ReadRawAsync(
    "SELECT Id, Title, IsDone FROM todos",
    param: null,
    (in RowReader r) =>
    {
        var id = r.Get<long>(0);
        var title = r.Get<string>(1);
        var isDone = r.Get<bool>(2);
        Console.WriteLine($"{id}: {title} ({(isDone ? "done" : "pending")})");
    });

// Sync version
db.ReadRaw("SELECT Id, Title FROM todos", null, (in RowReader r) =>
{
    Console.WriteLine($"{r.Get<long>(0)}: {r.Get<string>(1)}");
});

When to use RowReader:

  • Aggregation without creating objects (counting, summing)
  • Streaming to files (CSV/JSON export)
  • High-frequency queries where GC pressure matters
  • Data transformation/migration between databases

RowReader API:

Method Description
Get<T>(int ordinal) Returns value at column index
IsDbNull(int ordinal) Checks if column is NULL
FieldCount Number of columns in row

Mapping Rules (POCO/DTO)

  • Column-to-property matching is case-insensitive by default.
  • [ColumnInfo(Name="...")] overrides the expected column name.
  • [Ignore] excludes a property from mapping.
  • [TypeConverter(ConverterType=...)] converts the provider value into the property type.
  • Extra columns are ignored; missing columns keep the property at its default value.
  • For non-scalar mapping, T must have a public parameterless constructor, and properties must be public settable.
  • For scalar mapping (string or value types), the first column is read (null/DBNull becomes default).
public sealed class TodoDto
{
    public long Id { get; set; }
    
    [ColumnInfo(Name = "Title")]
    public required string Name { get; set; }
    
    [Ignore]
    public string? ComputedField { get; set; }
    
    [TypeConverter(ConverterType = typeof(JsonConverter<List<string>>))]
    public List<string> Tags { get; set; } = [];
}

Parameter Binding

  • Parameters are provided as an anonymous object / POCO, and its public readable properties become parameters (cached per parameter type).
  • Property names are used as the parameter name (most providers match @id in SQL to parameter name id).
  • null becomes DBNull.Value.
// Anonymous object
var todos = await db.QueryAsync<Todo>(
    "SELECT * FROM todos WHERE CategoryId = @categoryId AND IsDone = @isDone",
    new { categoryId = 5, isDone = false });

// POCO parameter object
public record TodoFilter(int CategoryId, bool IsDone);

var filter = new TodoFilter(5, false);
var todos = await db.QueryAsync<Todo>(
    "SELECT * FROM todos WHERE CategoryId = @CategoryId AND IsDone = @IsDone",
    filter);

Concurrency & Thread-Safety

QueryExtensions is fully integrated with RoomSharp's concurrency model:

  • Serialized mode (default): All extension methods automatically acquire the database gate before executing. This ensures thread-safe access to the shared connection.
  • Parallel mode: Use DbSession overloads for concurrent operations. Each session has its own connection.
// Serialized mode - automatically thread-safe
var todos = await db.QueryAsync<Todo>("SELECT * FROM todos");

// Parallel mode - use sessions for concurrency
await using var session1 = await db.OpenSessionAsync();
await using var session2 = await db.OpenSessionAsync();

// These can run concurrently
var task1 = session1.QueryAsync<Todo>("SELECT * FROM todos WHERE CategoryId = 1");
var task2 = session2.QueryAsync<Todo>("SELECT * FROM todos WHERE CategoryId = 2");

await Task.WhenAll(task1.AsTask(), task2.AsTask());

Performance Notes

Aspect Details
Cold path First call per (T, SQL, column layout) builds an IL materializer; first call per parameter type builds an IL binder
Hot path Reuses cached delegates; no reflection in the steady state
Gate overhead ~100-200ns per call in Serialized mode (negligible compared to SQL execution time)
Streaming QueryStreamAsync holds the command/reader open for the duration of enumeration

Supported Type Patterns

The materializer supports:

  • Classes with parameterless constructor (traditional POCOs)
  • Record types with primary constructors: public record User(long Id, string Name);
  • Classes with primary constructors (C# 12+): public class User(long id, string name)
  • Constructor parameters are matched to column names (case-insensitive)
  • Default parameter values are used when columns are missing
Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 is compatible.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.1.0 93 12/19/2025