Jattac.Libraries.QBuilder
9.0.0
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
<PackageReference Include="Jattac.Libraries.QBuilder" Version="9.0.0" />
<PackageVersion Include="Jattac.Libraries.QBuilder" Version="9.0.0" />
<PackageReference Include="Jattac.Libraries.QBuilder" />
paket add Jattac.Libraries.QBuilder --version 9.0.0
#r "nuget: Jattac.Libraries.QBuilder, 9.0.0"
#:package Jattac.Libraries.QBuilder@9.0.0
#addin nuget:?package=Jattac.Libraries.QBuilder&version=9.0.0
#tool nuget:?package=Jattac.Libraries.QBuilder&version=9.0.0
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.
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: User → tUser, Order → tOrder. The schema prefix is stripped when using a custom resolver — dbo.Users → tUsers.
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
Always use parameterized mode (
Q.New()) with user-supplied values. The default is safe on purpose.Define domain models as plain classes. QBuilder only uses the type name and property names. No attributes, no base class, no EF dependency.
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);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();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()Log the SQL during development:
var built = qb.BuildWithParameters(sql => Console.WriteLine(sql));Do not reuse a
QBuilderinstance — create a new one per query.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 | Versions 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. |
-
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.
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().