Jattac.Libraries.QBuilder 7.0.0-beta01

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

Jattac.Libraries.QBuilder

A fully-fledged C# dialect of SQL — a fluent, type-safe query builder for .NET that covers every clause in ANSI SQL plus the SQL Server and MariaDB/MySQL dialects.

NuGet License: MIT


Why QBuilder?

Writing raw SQL strings in application code is fragile — typos are runtime errors, refactors are grep-and-pray, and parameterization is tedious to get right. ORMs solve some of this but are heavy and leak abstractions.

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

Key properties:

  • Zero boilerplate — one fluent chain, no sub-builder ceremonies
  • Parameterized by default — injection-safe without extra effort
  • Full SQL coverage — SELECT, JOIN (all 5 types), WHERE, GROUP BY, HAVING, ORDER BY, paging (ROW_NUMBER, OFFSET/FETCH, LIMIT), UNION/INTERSECT/EXCEPT, CTEs, CASE WHEN, EXISTS, BETWEEN, IS NULL
  • Every public member has XML doc comments — your IDE guides you at every step
  • 100% unit-tested — 97 tests, 0 failures

Installation

dotnet add package Jattac.Libraries.QBuilder

Quick start

using Jattac.Libraries.QBuilder;         // Q, QBuilder
using Jattac.Libraries.QBuilder.Enums;  // FilterOperator, AggregateFunction

// 1. Build a parameterized query (safe by default)
var result = Q.Build()                                   // parameterize: true
    .Select<User, Guid>(u => u.Id)
    .Select<User, string>(u => u.Name, alias: "UserName")
    .Where<User, bool>(u => u.Active, FilterOperator.EqualTo, true)
    .OrderBy<User, string>(u => u.Name)
    .BuildWithParameters();

// result.ParameterizedSql — the SQL string with @Name0 placeholders
// result.Parameters       — Dictionary<string, object> { "@Active0": true }

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

Core concepts

Entry point — Q.Build()

The static Q class is the single entry point for all queries.

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

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

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

The custom resolver is useful when table names differ from CLR type names (plural names, schema prefixes, legacy naming conventions).

Table aliases

Every table gets an alias derived from its CLR type name: UsertUser, OrdertOrder.

When a custom resolver maps to schema-qualified names (dbo.Users), the schema prefix is stripped automatically — tUsers not tdbo.Users.

Parameterized vs literal mode

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

Always use parameterized mode with any user-supplied value. WhereExplicitly() (raw SQL injection point) throws in parameterized mode as a guard rail.


Step-by-step walkthrough

1. SELECT

Q.Build(false)
    .Select<User, Guid>(u => u.Id)                        // single column
    .Select<User, string>(u => u.Name, alias: "UserName") // with alias
    .Select<Order, decimal>(o => o.Amount)                // from another table (after joining)
    .Build();
// Aggregate functions
.Aggregate<Order, decimal>(o => o.Amount, "Total",   AggregateFunction.Sum)
.Aggregate<Order, Guid>  (o => o.Id,     "Count",   AggregateFunction.Count)
.Aggregate<Order, decimal>(o => o.Amount, "MaxAmt",  AggregateFunction.Max)
.Aggregate<Order, decimal>(o => o.Amount, "MinAmt",  AggregateFunction.Min)
.Aggregate<Order, decimal>(o => o.Amount, "Average", AggregateFunction.Avg)
.Aggregate<Order, Guid>  (o => o.Id,     "Unique",  AggregateFunction.CountDistinct)
// Modifiers
.Top(100)       // SELECT TOP 100
.Distinct()     // SELECT DISTINCT

2. JOINS

// INNER JOIN (most common)
.InnerJoin<User, Order, Guid, Guid>(u => u.Id, o => o.UserId)

// LEFT JOIN — all users, even those with no orders
.LeftJoin<User, Order, Guid, Guid>(u => u.Id, o => o.UserId)

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

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

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

3. WHERE

// First predicate
.Where<User, string>(u => u.Name, FilterOperator.EqualTo, "Alice")

// Additional predicates
.AndWhere<User, int>(u => u.Age, FilterOperator.GreaterThan, 18)
.OrWhere<User, string>(u => u.Name, FilterOperator.StartsWith, "A")

// NULL checks
.WhereIsNull<Order, DateTime?>(o => o.DeletedAt)
.AndWhereIsNotNull<User, string>(u => u.Name)

// Range check
.WhereBetween<User, int>(u => u.Age, 18, 65)
.AndWhereBetween<Order, decimal>(o => o.Amount, 100, 1000)

// Set membership
.WhereIn<Order, string, string>(o => o.Status, new[] { "new", "processing" })
.WhereNotIn<Order, string, string>(o => o.Status, new[] { "cancelled" })

// Subquery existence
.WhereExists(subQueryBuilder)
.AndWhereExists(subQueryBuilder)
.WhereNotExists(subQueryBuilder)

// Parenthesis groups (nestable)
.Where<Order, string>(o => o.Status, FilterOperator.EqualTo, "new")
.OpenGroup()
    .OrWhere<Order, decimal>(o => o.Amount, FilterOperator.GreaterThan, 100)
    .OrWhere<Order, decimal>(o => o.Amount, FilterOperator.LessThan, 5)
.CloseGroup()
All FilterOperator values
Operator SQL
EqualTo =
NotEqualTo <>
LessThan <
LessThanOrEqualTo <=
GreaterThan >
GreaterThanOrEqualTo >=
StartsWith Like 'value%'
Contains Like '%value%'
EndsWith Like '%value'
IsNull IS NULL
IsNotNull IS NOT NULL
Between BETWEEN (use WhereBetween)
NotBetween NOT BETWEEN (use WhereNotBetween)

4. GROUP BY and HAVING

Q.Build(false)
    .Aggregate<Order, decimal>(o => o.Amount, "Total", AggregateFunction.Sum)
    .InnerJoin<User, Order, Guid, Guid>(u => u.Id, o => o.UserId)
    .GroupBy<Order, Guid>(o => o.UserId)
    .Having<Order, decimal>(o => o.Amount, FilterOperator.GreaterThan, 100)
    .Build();

5. ORDER BY

.OrderBy<User, string>(u => u.Name)                      // ASC
.OrderByDescending<User, DateTime>(u => u.CreatedAt)     // DESC

// Multiple columns — call in priority order
.OrderBy<User, string>(u => u.LastName)
.ThenBy<User, string>(u => u.FirstName)
.ThenByDescending<User, DateTime>(u => u.CreatedAt)

6. Paging

Choose the flavor for your database:

// SQL Server (ROW_NUMBER — works on SQL Server 2005+)
.PageSqlServer<User, string>(u => u.Name, page: 1, pageSize: 20)

// SQL Server 2012+ / ANSI SQL (OFFSET FETCH)
.PageOffsetFetch<User, string>(u => u.Name, page: 2, pageSize: 20)

// MySQL / MariaDB (LIMIT OFFSET)
.PageMySql<User, string>(u => u.Name, page: 1, pageSize: 20)

Pages are 1-based — page 1 is the first page.

7. 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.Build(false)
    .Select<Order, Guid>(o => o.Id)
    .SelectCaseWhen(statusLabel, alias: "StatusLabel")
    .Build();

8. UNION / UNION ALL / INTERSECT / EXCEPT

var activeUsers = Q.Build(false).Select<User, Guid>(u => u.Id)
    .Where<User, bool>(u => u.Active, FilterOperator.EqualTo, true);

var premiumUsers = Q.Build(false).Select<User, Guid>(u => u.Id)
    .Where<User, string>(u => u.Tier, FilterOperator.EqualTo, "premium");

// Set operations — chain on the left-hand query before calling Build()
var sql = activeUsers
    .UnionAll(premiumUsers)   // or .Union() .Intersect() .Except()
    .Build();

Important: Call Build() on the combined query, not on the individual sub-queries beforehand.

9. Common Table Expressions (CTEs)

var activeOrders = Q.Build(false)
    .Select<Order, Guid>(o => o.Id)
    .Where<Order, string>(o => o.Status, FilterOperator.EqualTo, "active");

var sql = Q.Build(false)
    .WithCte("ActiveOrders", activeOrders)
    .Select<User, Guid>(u => u.Id)
    .Build();

// Emits:
// With ActiveOrders As (
//   ...inner query...
// )
// Select * from (...) as t

Multiple CTEs are comma-separated automatically:

Q.Build(false)
    .WithCte("CTE1", query1)
    .WithCte("CTE2", query2)
    .Select<User, string>(u => u.Name)
    .Build();

Complete real-world example

// "Get paged list of active users with their total order amounts,
//  filtered to users with total > 500, ordered by total descending"

var result = Q.Build()
    .Select<User, Guid>(u => u.Id)
    .Select<User, string>(u => u.Name, alias: "UserName")
    .Aggregate<Order, decimal>(o => o.Amount, "TotalAmount", AggregateFunction.Sum)
    .LeftJoin<User, Order, Guid, Guid>(u => u.Id, o => o.UserId)
    .Where<User, bool>(u => u.Active, FilterOperator.EqualTo, true)
    .AndWhereIsNull<User, DateTime?>(u => u.DeletedAt)
    .GroupBy<Order, Guid>(o => o.UserId)
    .Having<Order, decimal>(o => o.Amount, FilterOperator.GreaterThan, 500)
    .OrderByDescending<Order, decimal>(o => o.Amount)
    .PageSqlServer<Order, decimal>(o => o.Amount, page: 1, pageSize: 25)
    .BuildWithParameters();

var rows = await connection.QueryAsync(result.ParameterizedSql, result.Parameters);

Pitfalls and edge cases

Build() is single-use

var qb = Q.Build(false).Select<User, Guid>(u => u.Id);
var sql1 = qb.Build();   // OK
var sql2 = qb.Build();   // throws InvalidOperationException — create a new QBuilder

Create a new QBuilder for each query execution.

WhereIn / WhereNotIn with null or empty collections

// Both of these silently no-op — no WHERE clause is emitted
.WhereIn<Order, string, string>(o => o.Status, null)
.WhereIn<Order, string, string>(o => o.Status, new string[0])

This is intentional — it makes it safe to pass optional filter lists. If you need an impossible condition (1=0), emit it explicitly.

WhereExplicitly is blocked in parameterized mode

var qb = Q.Build();   // parameterize: true
qb.UseFilter().WhereExplicitly("Status = 'active'");  // throws InvalidOperationException

Use a typed Where overload instead, which binds values as parameters.

OpenGroup / CloseGroup must be balanced

Every OpenGroup() must be paired with a CloseGroup(). QBuilder validates this and throws at Build() time:

qb.OpenGroup()
    .OrWhere<Order, string>(o => o.Status, FilterOperator.EqualTo, "a")
// Missing CloseGroup() → Build() throws "An unclosed parentheses was found"

Set operations consume sub-queries eagerly

When you call Union(other), other.Build() is called immediately. Any changes made to other after this call are ignored.

Parameterized mode and BuildWithParameters

// Wrong — Build() throws if parameterize: true
Q.Build().Select<User, Guid>(u => u.Id).Build();

// Right
Q.Build().Select<User, Guid>(u => u.Id).BuildWithParameters();

// Also wrong — BuildWithParameters() throws if parameterize: false
Q.Build(false).Select<User, Guid>(u => u.Id).BuildWithParameters();

Best practices

  1. Always use parameterized mode with user-provided values. Q.Build() defaults to parameterize: true for a reason.

  2. Define domain models as plain classes, not EF entities. QBuilder only uses the type name and property names — no attributes needed.

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

    // Register once at application start
    var resolver = (Type t) => $"dbo.{t.Name}s";
    var qb = Q.Build(resolver);
    
  4. Inject Q.Build(resolver) factories in DI-based apps rather than calling Q.Build() directly everywhere — this avoids scattering the resolver logic.

  5. For complex queries, build sub-queries first:

    var existsCheck = Q.Build(false)
        .Select<Order, Guid>(o => o.Id)
        .Where<Order, Guid>(o => o.UserId, FilterOperator.EqualTo, userId);
    
    var main = Q.Build(false)
        .Select<User, Guid>(u => u.Id)
        .WhereExists(existsCheck)
        .Build();
    
  6. Do not reuse a QBuilder instance. Build, execute, discard.

  7. Test your queries in non-parameterized mode first to inspect the SQL, then switch to parameterized mode for production.


Backward compatibility

The v7.0 extension API coexists with the original Use*()/Then() API — all existing code compiles without changes. The extension methods are purely additive.

// Old API — still works
new QBuilder("t", parameterize: false)
    .UseTableBoundSelector<User>()
    .Select(u => u.Id)
    .Then()
    .UseFilter()
    .Where<User>("Name", FilterOperator.EqualTo, "Alice")
    .Then().Build();

// New API — same result
Q.Build(false)
    .Select<User, Guid>(u => u.Id)
    .Where<User, string>(u => u.Name, FilterOperator.EqualTo, "Alice")
    .Build();

Window functions (v7.1 roadmap)

LAG, LEAD, RANK, DENSE_RANK, ROW_NUMBER OVER (PARTITION BY), SUM OVER — these are planned for v7.1. The ROW_NUMBER() paging infrastructure already exists; the full window-function surface will be layered on top.


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.

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 166 6/28/2026
9.2.3 212 6/20/2026
9.2.2 128 6/20/2026
9.2.1 118 6/19/2026
9.2.0 220 6/17/2026
9.1.1 116 6/17/2026
9.1.0 119 6/17/2026
9.0.0 115 6/16/2026
7.0.0-rc2 118 6/14/2026
7.0.0-rc1 110 6/14/2026
7.0.0-beta01 111 6/14/2026

v7.0.0-beta01 — Full SQL fluent API: UNION/INTERSECT/EXCEPT, CTEs, CASE WHEN, EXISTS, BETWEEN, IS NULL, paging (ROW_NUMBER, OFFSET/FETCH, LIMIT/OFFSET), all 5 JOIN types, GROUP BY, HAVING, ORDER BY, and parameterization throughout. See https://github.com/nyingimaina/Jattac.Libraries.QBuilder/blob/master/CHANGELOG.md for full details.