Jattac.Libraries.QBuilder 9.0.0

There is a newer version of this package available.
See the version list below for details.
dotnet add package Jattac.Libraries.QBuilder --version 9.0.0
                    
NuGet\Install-Package Jattac.Libraries.QBuilder -Version 9.0.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="Jattac.Libraries.QBuilder" Version="9.0.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Jattac.Libraries.QBuilder" Version="9.0.0" />
                    
Directory.Packages.props
<PackageReference Include="Jattac.Libraries.QBuilder" />
                    
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 Jattac.Libraries.QBuilder --version 9.0.0
                    
#r "nuget: Jattac.Libraries.QBuilder, 9.0.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 Jattac.Libraries.QBuilder@9.0.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=Jattac.Libraries.QBuilder&version=9.0.0
                    
Install as a Cake Addin
#tool nuget:?package=Jattac.Libraries.QBuilder&version=9.0.0
                    
Install as a Cake Tool

Jattac.Libraries.QBuilder

A fully-fledged C# dialect of SQL — a fluent, type-safe query builder for .NET 6+ that covers SELECT (full ANSI SQL), DML (INSERT, UPDATE, DELETE), and SQL Server / MySQL/MariaDB dialect extensions.

NuGet License: MIT


Why QBuilder?

Raw SQL strings in application code are fragile — typos are runtime errors, refactors are grep-and-pray, and parameterization is manual. ORMs are heavy and leak abstractions.

QBuilder sits in the middle: it is purely a query builder. It produces SQL strings (optionally with named parameters) that you execute yourself with Dapper, ADO.NET, or any micro-ORM. No tracking, no migrations, no change detection — just clean, tested SQL.

Key properties

  • Zero boilerplate — one fluent chain, every type argument inferred, no sub-builder ceremonies
  • Parameterized by default — injection-safe without extra effort
  • Full SQL coverage — SELECT, JOIN (5 types), WHERE, GROUP BY, HAVING, ORDER BY, paging (ROW_NUMBER, OFFSET/FETCH, LIMIT), UNION / INTERSECT / EXCEPT, CTEs, CASE WHEN, EXISTS, BETWEEN, IS NULL, IN
  • Full DML coverage — type-safe INSERT, UPDATE (with SET), DELETE, all with parameterized output and optional SQL logger; bare-table UPDATE/DELETE blocked by default
  • Every public member has XML doc comments — your IDE guides you at every step
  • 151 unit + integration tests (including live SQLite), 0 failures

Installation

dotnet add package Jattac.Libraries.QBuilder

Quick start

using Jattac.Libraries.QBuilder;

// Build a parameterized query (safe by default)
var built = Q.New()
    .UseTableBoundSelector<User>().Column(u => u.Id).Column(u => u.Name, "UserName").Then()
    .UseTableBoundFilter<User>()
        .WhereEqualTo(u => u.IsActive, true)
        .AndWhereIsNull(u => u.DeletedAt)
    .Then()
    .UseTableBoundOrderBy<User>().Ascending(u => u.Name).Then()
    .BuildWithParameters();

// Execute with Dapper
var users = await connection.QueryAsync<User>(built.ParameterizedSql, built.Parameters);

Core concepts

Entry point — Q.New()

// Parameterized (default) — call BuildWithParameters() at the end
var qb = Q.New();

// Non-parameterized — call Build() at the end
var qb = Q.New(parameterize: false);

// Custom table-name resolver — map CLR types to SQL table names
var qb = Q.New(t => "dbo." + t.Name + "s");  // User → "dbo.Users"

The fluent chain pattern

Every clause entry point is a method on QBuilder returning a typed builder. Predicates return the same builder for chaining. .Then() exits back to QBuilder for the next clause.

SELECT / JOIN / WHERE / GROUP / ORDER chain:
  Q.New()
    .UseTableBoundSelector<T>()      → TableBoundSelectBuilder<T>     → .Then() → QBuilder
    .UseTableBoundFilter<T>()        → TableBoundWhereBuilder<T>      → .Then() → QBuilder
    .UseTableBoundHaving<T>()        → TableBoundHavingBuilder<T>     → .Then() → QBuilder
    .UseTableBoundOrderBy<T>()       → TableBoundOrderByBuilder<T>    → .Then() → QBuilder
    .UseTableBoundGrouper<T>()       → TableBoundGroupBuilder<T>      → GroupBy() returns QBuilder directly
    .UseTableBoundJoinBuilder<L,R>() → TableBoundJoinBuilder<L,R>     → InnerJoin/LeftJoin returns QBuilder directly
    .Build() / .BuildWithParameters()

DML terminal chains (do NOT chain back to QBuilder.Build()):
  Q.New()
    .UseTableBoundInsert<T>()  → TableBoundInsertBuilder<T>  → .Value() → ... → .BuildWithParameters()
    .UseTableBoundUpdate<T>()  → TableBoundUpdateBuilder<T>  → .Set() + .Where*() → .BuildWithParameters()
    .UseTableBoundDelete<T>()  → TableBoundDeleteBuilder<T>  → .Where*() → .BuildWithParameters()

Table aliases

Every CLR type gets an alias prefix from its name: UsertUser, OrdertOrder. The schema prefix is stripped when using a custom resolver — dbo.UserstUsers.

Parameterized vs literal mode

Mode Entry Build call Use when
Parameterized Q.New() BuildWithParameters() Production — user-supplied values
Literal Q.New(false) Build() Reporting, internal tooling, debugging

SELECT

Q.New(false)
    // Single column
    .UseTableBoundSelector<User>().Column(u => u.Id).Then()

    // Column with alias
    .UseTableBoundSelector<User>().Column(u => u.Name, "UserName").Then()

    // Modifiers — call before Column()
    .UseTableBoundSelector<User>().Distinct().Column(u => u.Email).Then()
    .UseTableBoundSelector<User>().Top(100).Column(u => u.Name).Then()

    // Multiple columns in one builder scope
    .UseTableBoundSelector<User>()
        .Column(u => u.Id)
        .Column(u => u.Name, "UserName")
        .Column(u => u.Email)
    .Then()

    // Aggregates
    .UseTableBoundSelector<Order>().Aggregate(o => o.Amount, "Total",  AggregateFunction.Sum).Then()
    .UseTableBoundSelector<Order>().Aggregate(o => o.Amount, "MaxAmt", AggregateFunction.Max).Then()
    .UseTableBoundSelector<Order>().Aggregate(o => o.Amount, "MinAmt", AggregateFunction.Min).Then()
    .UseTableBoundSelector<Order>().Aggregate(o => o.Amount, "Avg",    AggregateFunction.Avg).Then()
    .UseTableBoundSelector<Order>().Aggregate(o => o.Id,     "Cnt",    AggregateFunction.Count).Then()
    .UseTableBoundSelector<Order>().Aggregate(o => o.Id,     "Unique", AggregateFunction.CountDistinct).Then()

    .Build();

CASE WHEN

var statusLabel = CaseWhenBuilder.For<Order>()
    .When<Order, string>(o => o.Status, FilterOperator.EqualTo, "active").Then("Active")
    .When<Order, string>(o => o.Status, FilterOperator.EqualTo, "closed").Then("Closed")
    .Else("Unknown");

Q.New(false)
    .UseTableBoundSelector<Order>()
        .Column(o => o.Id)
        .CaseWhen(statusLabel, "StatusLabel")
    .Then()
    .Build();

JOIN

// All join methods return QBuilder directly — no .Then() needed

// INNER JOIN
Q.New(false)
    .UseTableBoundSelector<User>().Column(u => u.Id).Then()
    .UseTableBoundSelector<Order>().Column(o => o.Amount).Then()
    .UseTableBoundJoinBuilder<User, Order>().InnerJoin(u => u.Id, o => o.UserId)
    .Build();

// LEFT JOIN
.UseTableBoundJoinBuilder<User, Order>().LeftJoin(u => u.Id, o => o.UserId)

// RIGHT JOIN
.UseTableBoundJoinBuilder<Order, Product>().RightJoin(o => o.ProductId, p => p.Id)

// FULL OUTER JOIN
.UseTableBoundJoinBuilder<User, Order>().FullJoin(u => u.Id, o => o.UserId)

// CROSS JOIN (Cartesian product — no ON clause)
.UseJoiner().CrossJoin<User, Product>().Then()

// Self-join — use explicit aliases to disambiguate
.UseTableBoundJoinBuilder<Employee, Employee>()
    .InnerJoin(e => e.ManagerId, m => m.Id, leftAlias: "tEmp", rightAlias: "tMgr")

WHERE

Every predicate is a method on TableBoundWhereBuilder<T>. Call .Then() to exit back to QBuilder.

.UseTableBoundFilter<User>()

    // ── equality ─────────────────────────────────────────────────────────────
    .WhereEqualTo(u => u.Name, "Alice")
    .WhereNotEqualTo(u => u.Status, "banned")

    // ── comparison ───────────────────────────────────────────────────────────
    .WhereGreaterThan(u => u.Age, 18)
    .WhereGreaterThanOrEqualTo(u => u.Age, 18)
    .WhereLessThan(u => u.Age, 65)
    .WhereLessThanOrEqualTo(u => u.Age, 65)

    // ── string search ─────────────────────────────────────────────────────────
    .WhereContains(u => u.Name, "ali")            // LIKE '%ali%'
    .WhereStartsWith(u => u.Name, "Al")           // LIKE 'Al%'
    .WhereEndsWith(u => u.Name, "ce")             // LIKE '%ce'

    // ── null checks ───────────────────────────────────────────────────────────
    .WhereIsNull(u => u.DeletedAt)
    .WhereIsNotNull(u => u.DeletedAt)

    // ── range ─────────────────────────────────────────────────────────────────
    .WhereBetween(u => u.Age, 18, 65)
    .WhereNotBetween(u => u.CreatedAt, cutoffStart, cutoffEnd)

    // ── set membership ────────────────────────────────────────────────────────
    .WhereIn<string, string>(u => u.Status, new[] { "active", "pending" })
    .WhereNotIn<string, string>(u => u.Status, new[] { "banned" })

    // ── subquery existence ────────────────────────────────────────────────────
    .WhereExists(subQueryBuilder)
    .WhereNotExists(subQueryBuilder)

.Then()

AND / OR continuation

All predicates have And* and Or* variants. They all return the same builder instance — stay in scope until .Then().

.UseTableBoundFilter<User>()
    .WhereEqualTo(u => u.IsActive, true)
    .AndWhereIsNull(u => u.DeletedAt)
    .AndWhereGreaterThan(u => u.Age, 18)
    .OrWhereEqualTo(u => u.Role, "admin")
    .OrWhereIn<string, string>(u => u.Status, new[] { "premium", "vip" })
.Then()

Full families available: And/OrWhereEqualTo, And/OrWhereNotEqualTo, And/OrWhereLessThan, And/OrWhereLessThanOrEqualTo, And/OrWhereGreaterThan, And/OrWhereGreaterThanOrEqualTo, And/OrWhereContains, And/OrWhereStartsWith, And/OrWhereEndsWith, And/OrWhereIsNull, And/OrWhereIsNotNull, And/OrWhereIn, And/OrWhereNotIn, And/OrWhereBetween, And/OrWhereNotBetween, And/OrWhereExists, And/OrWhereNotExists.

Grouping predicates with parentheses

.UseTableBoundFilter<Order>()
    .WhereEqualTo(o => o.UserId, currentUserId)
    .OpenGroup()
        .WhereEqualTo(o => o.Status, "new")
        .OrWhereEqualTo(o => o.Status, "processing")
    .CloseGroup()
.Then()

// SQL: WHERE tOrder.UserId = @UserId0 And ( tOrder.Status = @Status0 Or tOrder.Status = @Status1 )

Every OpenGroup() must be paired with a CloseGroup(). Nesting is supported.

Conditional filter — .If()

string nameFilter = Request.Query["name"];

.UseTableBoundFilter<User>()
    .If(!string.IsNullOrEmpty(nameFilter),
        fb => fb.WhereContains(u => u.Name, nameFilter))
    .If(showOnlyActive,
        fb => fb.AndWhereEqualTo(u => u.IsActive, true))
.Then()

When the condition is false the lambda is not invoked — the chain continues unbroken.

Raw SQL escape hatch

// Non-parameterized only — use for expressions the builder can't express
.UseTableBoundFilter<User>().WhereExplicitly("YEAR(tUser.CreatedAt) = 2024").Then()

// Parameterized with manual parameters
.UseTableBoundFilter<User>()
    .WhereExplicitly("YEAR(tUser.CreatedAt) = @year", new { year = 2024 })
.Then()

WhereExplicitly(string) (single-arg) throws in parameterized mode as a guard rail. Use the two-arg overload with an anonymous object to supply parameters safely.


GROUP BY / HAVING

Q.New(false)
    .UseTableBoundSelector<Order>()
        .Column(o => o.UserId)
        .Aggregate(o => o.Amount, "Total", AggregateFunction.Sum)
    .Then()
    .UseTableBoundJoinBuilder<User, Order>().InnerJoin(u => u.Id, o => o.UserId)
    .UseTableBoundGrouper<Order>().GroupBy(o => o.UserId)       // returns QBuilder
    .UseTableBoundHaving<Order>().HavingGreaterThan(o => o.Amount, 500).Then()
    .Build();

GroupBy() returns QBuilder directly (no .Then() needed). Chain UseTableBoundHaving<T>() directly after it.

HAVING predicates

.UseTableBoundHaving<Order>()
    .HavingEqualTo(o => o.Status, "active")
    .AndHavingGreaterThan(o => o.Amount, 100)
    .OrHavingLessThan(o => o.Amount, 10)
    .HavingIsNull(o => o.DeletedAt)
.Then()

Available: Having/AndHaving/OrHaving + EqualTo, NotEqualTo, GreaterThan, GreaterThanOrEqualTo, LessThan, LessThanOrEqualTo, IsNull, IsNotNull.


ORDER BY

.UseTableBoundOrderBy<User>()
    .Ascending(u => u.Name)              // single column ASC
.Then()

.UseTableBoundOrderBy<User>()
    .Descending(u => u.CreatedAt)        // single column DESC
.Then()

// Multi-column — chain ThenAscending / ThenDescending
.UseTableBoundOrderBy<User>()
    .Ascending(u => u.LastName)
    .ThenAscending(u => u.FirstName)
    .ThenDescending(u => u.CreatedAt)
.Then()

PAGING

Choose the variant for your database engine. All paging builders expose .PageBy(fieldSelector, page, pageSize).Build() and terminate the chain — no additional .Build() call needed.

// SQL Server (ROW_NUMBER — SQL Server 2005+)
Q.New(false)
    .UseTableBoundSelector<User>().Column(u => u.Id).Then()
    .UseSqlServerPagingBuilder<User>().PageBy(u => u.Name, page: 1, pageSize: 20)
    .Build();

// SQL Server 2012+ / ANSI SQL (OFFSET … ROWS FETCH NEXT … ROWS ONLY)
Q.New(false)
    .UseTableBoundSelector<User>().Column(u => u.Id).Then()
    .UseOffsetFetchPagingBuilder<User>().PageBy(u => u.Name, page: 2, pageSize: 20)
    .Build();

// MySQL / MariaDB (LIMIT … OFFSET …)
Q.New(false)
    .UseTableBoundSelector<User>().Column(u => u.Id).Then()
    .UseMySqlServerPagingBuilder<User>().PageBy(u => u.Name, page: 1, pageSize: 20)
    .Build();

Pages are 1-based. Page 1 = first page, rows 1–pageSize.


CTEs

var activeOrders = Q.New(false)
    .UseTableBoundSelector<Order>().Column(o => o.Id).Column(o => o.Amount).Then()
    .UseTableBoundFilter<Order>().WhereEqualTo(o => o.Status, "active").Then();

var sql = Q.New(false)
    .WithCte("ActiveOrders", activeOrders)
    .UseTableBoundSelector<User>().Column(u => u.Id).Then()
    .Build();
// Emits: With ActiveOrders As (...) Select * from (...) as t

Multiple CTEs are comma-separated automatically:

Q.New(false)
    .WithCte("CTE1", q1)
    .WithCte("CTE2", q2)
    .UseTableBoundSelector<User>().Column(u => u.Name).Then()
    .Build();

Set operations

var activeUsers = Q.New(false)
    .UseTableBoundSelector<User>().Column(u => u.Id).Then()
    .UseTableBoundFilter<User>().WhereEqualTo(u => u.IsActive, true).Then();

var premiumUsers = Q.New(false)
    .UseTableBoundSelector<User>().Column(u => u.Id).Then()
    .UseTableBoundFilter<User>().WhereEqualTo(u => u.Tier, "premium").Then();

var sql = activeUsers
    .UnionAll(premiumUsers)    // or .Union() .Intersect() .Except()
    .Build();

Call Build() on the combined query. The sub-queries must not have Build() called on them beforehand.


Parameterized mode — complete example

var built = Q.New()
    .UseTableBoundSelector<User>()
        .Column(u => u.Id)
        .Column(u => u.Name, "UserName")
    .Then()
    .UseTableBoundFilter<User>()
        .WhereEqualTo(u => u.IsActive, true)
        .AndWhereIsNull(u => u.DeletedAt)
        .If(!string.IsNullOrEmpty(nameFilter),
            fb => fb.AndWhereContains(u => u.Name, nameFilter))
    .Then()
    .UseTableBoundOrderBy<User>().Ascending(u => u.Name).Then()
    .BuildWithParameters();

// built.ParameterizedSql — SQL with @Name0 placeholders
// built.Parameters       — Dictionary<string, object>

// Optional: log the SQL for debugging
var built2 = Q.New()
    .UseTableBoundSelector<User>().Column(u => u.Id).Then()
    .BuildWithParameters(sql => logger.LogDebug(sql));

// Execute with Dapper
var users = await conn.QueryAsync<User>(built.ParameterizedSql, built.Parameters);

Full real-world example

// Paged list of active users with their total order amounts,
// filtered to totals > 500, ordered by total descending

var built = Q.New()
    .UseTableBoundSelector<User>()
        .Column(u => u.Id)
        .Column(u => u.Name, "UserName")
    .Then()
    .UseTableBoundSelector<Order>()
        .Aggregate(o => o.Amount, "TotalAmount", AggregateFunction.Sum)
    .Then()
    .UseTableBoundJoinBuilder<User, Order>().LeftJoin(u => u.Id, o => o.UserId)
    .UseTableBoundFilter<User>()
        .WhereEqualTo(u => u.IsActive, true)
        .AndWhereIsNull(u => u.DeletedAt)
    .Then()
    .UseTableBoundGrouper<Order>().GroupBy(o => o.UserId)
    .UseTableBoundHaving<Order>().HavingGreaterThan(o => o.Amount, 500).Then()
    .UseTableBoundOrderBy<Order>().Descending(o => o.Amount).Then()
    .UseSqlServerPagingBuilder<Order>().PageBy(o => o.Amount, page: 1, pageSize: 25)
    .Build();

DML — INSERT, UPDATE, DELETE

DML builders are terminal — they do not chain back through QBuilder.Build(). Call .BuildWithParameters() at the end to get a BuiltQuery you pass directly to Dapper's Execute.

INSERT

var q = Q.New()
    .UseTableBoundInsert<User>()
    .Value(u => u.Id, Guid.NewGuid())
    .Value(u => u.Name, "Alice")
    .Value(u => u.IsActive, true)
    .Value(u => u.DeletedAt, null)
    .BuildWithParameters();

// Dapper
await conn.ExecuteAsync(q.ParameterizedSql, q.Parameters);
// → Insert Into User (Id, Name, IsActive, DeletedAt) Values (@Id0, @Name0, @IsActive0, @DeletedAt0)

UPDATE

var q = Q.New()
    .UseTableBoundUpdate<User>()
    .Set(u => u.Name, "Alice Updated")
    .Set(u => u.IsActive, false)
    .WhereEqualTo(u => u.Id, userId)
    .BuildWithParameters();

await conn.ExecuteAsync(q.ParameterizedSql, q.Parameters);
// → Update User Set Name = @Name0, IsActive = @IsActive0 Where User.Id = @Id0

DELETE

var q = Q.New()
    .UseTableBoundDelete<User>()
    .WhereEqualTo(u => u.Id, userId)
    .BuildWithParameters();

await conn.ExecuteAsync(q.ParameterizedSql, q.Parameters);
// → Delete From User Where User.Id = @Id0

Full WHERE predicate surface on DML

All And*/Or* predicates available on TableBoundWhereBuilder<T> are available on DELETE and UPDATE:

Q.New()
    .UseTableBoundDelete<User>()
    .WhereEqualTo(u => u.IsActive, false)
    .AndWhereIsNotNull(u => u.DeletedAt)
    .BuildWithParameters();

Explicit full-table operations

Calling .BuildWithParameters() without any WHERE predicate throws InvalidOperationException. Use .ForEntireTable() to confirm you intend a full-table operation:

// Deactivate all users (intentional)
Q.New()
    .UseTableBoundUpdate<User>()
    .Set(u => u.IsActive, false)
    .ForEntireTable()
    .BuildWithParameters();

// Delete all soft-deleted records (intentional)
Q.New()
    .UseTableBoundDelete<User>()
    .ForEntireTable()
    .BuildWithParameters();

SQL logging on DML

All three DML builders accept an optional Action<string> logSql delegate on BuildWithParameters():

var q = Q.New()
    .UseTableBoundDelete<User>()
    .WhereEqualTo(u => u.Id, userId)
    .BuildWithParameters(sql => logger.LogDebug("DML: {Sql}", sql));

DML pitfalls and best practices

Always use Q.New() (parameterized mode) for DML. Embedding user-supplied values in a raw INSERT / UPDATE is as dangerous as raw SELECT.

ForEntireTable() is a safety acknowledgment, not a shortcut. If you find yourself calling it frequently, reconsider whether the WHERE clause should be required upstream in business logic.

Identity / auto-increment columns. QBuilder produces the INSERT statement only — it does not append ; SELECT LAST_INSERT_ID() or OUTPUT INSERTED.Id. Retrieve the generated ID via Dapper's ExecuteScalar or QuerySingle with the appropriate identity query for your database.

// SQL Server — get the inserted ID
var id = await conn.ExecuteScalarAsync<int>(insertSql + "; SELECT SCOPE_IDENTITY()", q.Parameters);

// MySQL
var id = await conn.ExecuteScalarAsync<long>(insertSql + "; SELECT LAST_INSERT_ID()", q.Parameters);

UPDATE Set() order matters for readability, not for SQL correctness — columns are emitted in .Set() call order.

No multi-table UPDATE or MERGE. Dialect-specific constructs (UPDATE … JOIN in MySQL, MERGE in SQL Server) are deferred. For those, use raw parameterized SQL via Dapper directly.


Pitfalls and edge cases

Build() is single-use

var qb = Q.New(false).UseTableBoundSelector<User>().Column(u => u.Id).Then();
var sql1 = qb.Build();   // OK
var sql2 = qb.Build();   // throws InvalidOperationException

Create a new QBuilder for each query execution. QBuilder is cheap to construct.

Calling Build() and BuildWithParameters() must match the mode

// Wrong — Build() throws when parameterize: true
Q.New().UseTableBoundSelector<User>().Column(u => u.Id).Then().Build();

// Wrong — BuildWithParameters() throws when parameterize: false
Q.New(false).UseTableBoundSelector<User>().Column(u => u.Id).Then().BuildWithParameters();

WhereIn / WhereNotIn with null or empty collections silently no-ops

// No WHERE clause is emitted — returns all rows
.UseTableBoundFilter<User>().WhereIn<string, string>(u => u.Status, null).Then()
.UseTableBoundFilter<User>().WhereIn<string, string>(u => u.Status, new string[0]).Then()

This is intentional — safe to pass optional filter lists. To emit an impossible condition, use .WhereExplicitly("1=0") in non-parameterized mode.

OpenGroup / CloseGroup must be balanced

.UseTableBoundFilter<Order>()
    .OpenGroup()
    .WhereEqualTo(o => o.Status, "new")
    // Missing CloseGroup() → Build() throws "An unclosed parentheses was found"
.Then()

Set operations consume sub-queries eagerly

Union(other) calls other.Build() immediately. Do not modify other after calling a set operation on it.

Booleans in parameterized mode

Pass C# bool values directly — they are stored as-is and ADO.NET converts them to integers for the database. Do not substitute 1/0 integers manually (both work, but true/false is idiomatic).

WhereExplicitly(string) throws in parameterized mode

// Throws — raw SQL injection point blocked in parameterized mode
Q.New().UseTableBoundFilter<User>().WhereExplicitly("Status = 'active'").Then()

// Safe alternative — use the two-arg overload
Q.New().UseTableBoundFilter<User>().WhereExplicitly("Status = @status", new { status = "active" }).Then()

[Column] attributes are ignored

TableBound* builders resolve column names from the C# property name, not from [Column] data annotations. If your model has [Column("is_active")] on a property named IsActive, the generated SQL uses tUser.IsActive. Use a custom table-name resolver at Q.New(t => ...) level if you need different column naming.

SQL reserved words as table names

QBuilder generates FROM TableName as tTableName without quoting. If your table name is a SQL reserved word (e.g. Order in SQLite, User in some dialects), use a custom resolver to quote it:

Q.New(t => t.Name == "Order" ? "[Order]" : t.Name, parameterize: false)

Best practices

  1. Always use parameterized mode (Q.New()) with user-supplied values. The default is safe on purpose.

  2. Define domain models as plain classes. QBuilder only uses the type name and property names. No attributes, no base class, no EF dependency.

  3. Use a custom resolver for non-trivial naming:

    // Register once (e.g. in DI setup)
    Func<Type, string> resolver = t => $"dbo.{t.Name}s";
    var qb = Q.New(resolver);
    
  4. Build sub-queries first for EXISTS / CTEs:

    var sub = Q.New(false)
        .UseTableBoundSelector<Order>().Column(o => o.Id).Then()
        .UseTableBoundFilter<Order>().WhereEqualTo(o => o.UserId, userId).Then();
    
    var sql = Q.New(false)
        .UseTableBoundSelector<User>().Column(u => u.Id).Then()
        .UseTableBoundFilter<User>().WhereExists(sub).Then()
        .Build();
    
  5. Use .If() for optional filters instead of conditional branching in calling code:

    // Good — stays in one chain
    .UseTableBoundFilter<User>()
        .WhereEqualTo(u => u.IsActive, true)
        .If(hasNameFilter, fb => fb.AndWhereContains(u => u.Name, nameFilter))
        .If(hasRoleFilter, fb => fb.AndWhereEqualTo(u => u.Role, roleFilter))
    .Then()
    
  6. Log the SQL during development:

    var built = qb.BuildWithParameters(sql => Console.WriteLine(sql));
    
  7. Do not reuse a QBuilder instance — create a new one per query.

  8. Inspect SQL in non-parameterized mode first, then switch to Q.New() for production.


Migration from v7 to v8

v8 removed QBuilderExtensions (the flat extension-method API). See docs/migration-v7-to-v8.md for the complete method-by-method mapping.

Quick summary:

// v7 (removed)
Q.New(false)
    .Select<User, string>(u => u.Name)
    .Where<User, bool>(u => u.IsActive, FilterOperator.EqualTo, true)
    .OrderBy<User, string>(u => u.Name)
    .Build();

// v8
Q.New(false)
    .UseTableBoundSelector<User>().Column(u => u.Name).Then()
    .UseTableBoundFilter<User>().WhereEqualTo(u => u.IsActive, true).Then()
    .UseTableBoundOrderBy<User>().Ascending(u => u.Name).Then()
    .Build();

Migration from Rocket.Libraries.QBuilder

Jattac.Libraries.QBuilder is the renamed successor to Rocket.Libraries.QBuilder.

Step 1 — replace the package reference


<PackageReference Include="Rocket.Libraries.QBuilder" Version="*" />


<PackageReference Include="Jattac.Libraries.QBuilder" Version="8.0.0" />

Step 2 — update namespace imports

// Before
using Rocket.Libraries.Qurious;

// After
using Jattac.Libraries.QBuilder;

Then migrate the call sites using the v8 TableBound* API — see docs/migration-v7-to-v8.md.


Window functions — roadmap

LAG, LEAD, RANK, DENSE_RANK, ROW_NUMBER OVER (PARTITION BY), SUM OVER are planned. The ROW_NUMBER paging infrastructure already exists; the full window-function surface will be layered on top in a future release.


License

MIT — see LICENSE.

Product Compatible and additional computed target framework versions.
.NET net6.0 is compatible.  net6.0-android was computed.  net6.0-ios was computed.  net6.0-maccatalyst was computed.  net6.0-macos was computed.  net6.0-tvos was computed.  net6.0-windows was computed.  net7.0 was computed.  net7.0-android was computed.  net7.0-ios was computed.  net7.0-maccatalyst was computed.  net7.0-macos was computed.  net7.0-tvos was computed.  net7.0-windows was computed.  net8.0 was computed.  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 was computed.  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 was computed.  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.
  • net6.0

    • No dependencies.

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
9.2.4 136 6/28/2026
9.2.3 143 6/20/2026
9.2.2 118 6/20/2026
9.2.1 109 6/19/2026
9.2.0 215 6/17/2026
9.1.1 111 6/17/2026
9.1.0 114 6/17/2026
9.0.0 110 6/16/2026
7.0.0-rc2 111 6/14/2026
7.0.0-rc1 105 6/14/2026
7.0.0-beta01 107 6/14/2026

v9.0.0 — DML release. New: TableBoundInsertBuilder<T>, TableBoundUpdateBuilder<T>, TableBoundDeleteBuilder<T> with full And*/Or* WHERE surface, ForEntireTable() bare-table guard, optional logSql delegate on BuildWithParameters(). All DML builders are terminal — call BuildWithParameters() directly, pass result to Dapper conn.Execute().