8.1 KiB
Typed Fluent Syntax
This page documents the new typed fluent syntax added alongside the original dynamic fluent builder API.
It is intended for cases where:
- you want property-based IntelliSense instead of raw column names
- you want mapped property validation in fluent SQL construction
- you still want explicit SQL composition instead of LINQ translation
The old dynamic fluent API remains unchanged. This typed API is additive.
Two Typed Styles
There are currently two typed select styles:
- positional typed builder:
db.FromTyped<T>("alias")
- scoped typed builder:
db.FromTypedScope<T>("alias")
Recommendation:
- use
FromTypedScope(...)for multi-join queries - use
FromTyped(...)for simple root-table typed queries or when you want direct positional generic overloads
Root Typed Builder
Basic example:
var cmd = db.FromTyped<User>("u")
.SelectSql(u => u.Col(x => x.Code))
.WhereSql(u => u.Col(x => x.Id).Eq(1));
Mapped properties are resolved through entity metadata:
[Table(Name = "sample_users")]
public class User
{
[Column("id_user", true)]
public long Id { get; set; }
[Column("user_code")]
public string Code { get; set; }
}
So:
u.Col(x => x.Id)renders mapped columnu."id_user"u.Col(x => x.Code)renders mapped columnu."user_code"
Core Building Blocks
The typed DSL is built around:
TypedTableContext<T>TypedSqlExpression<T>TypedSqlPredicateTypedSqlOrderExpressionSql.*helpers
Common building blocks:
u.Col(x => x.Id)
Sql.Val(1)
Sql.Count()
Sql.Coalesce<string>(u.Col(x => x.Code), Sql.Val("N/A"))
Sql.Case<string>().When(...).Else(...)
Positional Typed Builder
FromTyped(...) supports typed joins and then exposes joined tables by position.
Example:
var cmd = db.FromTyped<User>("u")
.Join<UserClient>(j => j.Left().As("c").OnSql((u, c) =>
u.Col(x => x.Id).Eq(c.Col(x => x.UserId))))
.SelectSql<UserClient>(
(u, c) => u.Col(x => x.Id).As("user_id"),
(u, c) => c.Col(x => x.ClientCode).As("client_code"))
.WhereSql<UserClient>((u, c) =>
c.Col(x => x.IsDeleted).Eq(0));
This style is explicit, but becomes noisy as join count grows because join types must be repeated in generic parameters.
Scoped Typed Builder
FromTypedScope(...) solves the repeated-generic problem by changing builder type after each join.
Example:
var cmd = db.FromTypedScope<User>("u")
.Join<UserClient>(j => j.Left().As("c").OnSql((u, c) =>
u.Col(x => x.Id).Eq(c.Col(x => x.UserId))))
.Join<UserRole>(j => j.Left().As("r").OnSql((u, c, r) =>
c.Col(x => x.UserId).Eq(r.Col(x => x.UserId))))
.SelectSql(
(u, c, r) => u.Col(x => x.Id),
(u, c, r) => r.Col(x => x.RoleName).As("role_name"))
.WhereSql((u, c, r) =>
r.Col(x => x.RoleId).Gt(0));
Important behavior:
- each
Join<TJoin>(...)extends the available typed lambda parameters - later joins can reference previously joined tables inside
OnSql(...) - this is the preferred typed syntax for larger join graphs
Current implementation supports up to 16 typed tables in one scoped chain.
Join Syntax
Typed joins support:
Inner()Join()Left()Right()Full()LeftOuter()RightOuter()FullOuter()Type("...")for custom join keywordsNoLock()OnSql(...)OnRaw(...)
Examples:
.Join<UserClient>(j => j.Left().As("c").OnSql((u, c) =>
u.Col(x => x.Id).Eq(c.Col(x => x.UserId))))
.Join<UserClient>(j => j.Type("CROSS APPLY").As("c").NoLock())
.Join<UserRole>(j => j.Left().As("r").OnRaw("c.\"User_Id\" = r.\"User_Id\""))
OnSql(...) is usually the best option because it stays mapped and typed.
Selecting Columns
Single-column select:
.SelectSql(u => u.Col(x => x.Code))
Aliased select:
.SelectSql(u => u.Col(x => x.Code).As("user_code"))
Wildcard:
.SelectSql(u => u.All())
Multi-table scoped select:
.SelectSql(
(u, c, r) => u.Col(x => x.Id).As("user_id"),
(u, c, r) => c.Col(x => x.ClientCode).As("client_code"),
(u, c, r) => r.Col(x => x.RoleName).As("role_name"))
Predicates
Available predicate patterns include:
EqNotEqGtGteLtLteIsNullIsNotNullLikeStartsWithEndsWithContainsInNotInBetweenAndOrNot
Example:
.WhereSql((u, c) =>
u.Col(x => x.Code).StartsWith("ADM")
.And(c.Col(x => x.IsDeleted).Eq(0))
.And(c.Col(x => x.ClientCode).IsNotNull()))
Functions and Expressions
Standard helpers:
Sql.Count()Sql.Count(expr)Sql.Sum(...)Sql.Avg(...)Sql.Min(...)Sql.Max(...)Sql.Abs(...)Sql.Upper(...)Sql.Lower(...)Sql.Trim(...)Sql.Length(...)Sql.NullIf(...)Sql.Coalesce(...)Sql.CurrentTimestamp()
Examples:
.SelectSql(
u => Sql.Count().As("cnt"),
u => Sql.Coalesce<string>(u.Col(x => x.Code), Sql.Val("N/A")).As("code_value"))
.HavingSql((u, c) => Sql.Count(c.Col(x => x.UserId)).Gt(0))
Arithmetic is also supported:
.SelectSql(u => (u.Col(x => x.Id) + Sql.Val(1)).As("plus_one"))
CASE Expressions
.SelectSql(u => Sql.Case<string>()
.When(u.Col(x => x.Code).Eq("A"), "Alpha")
.When(u.Col(x => x.Code).Eq("B"), "Beta")
.Else("Other")
.As("category"))
Use CASE when expression logic becomes too awkward for simple COALESCE or comparison predicates.
Grouping and Ordering
Grouping:
.GroupBySql((u, c) => u.Col(x => x.Id))
Ordering:
.OrderBySql(
(u, c) => c.Col(x => x.ClientCode).Asc(),
(u, c) => Sql.Count(c.Col(x => x.UserId)).Desc())
Null ordering helpers are also available:
NullsFirst()NullsLast()
Raw order fragments:
.OrderBySql(u => Sql.RawOrder("2 DESC"))
Custom SQL Escape Hatches
For cases not covered by built-in helpers:
Sql.Raw<T>(...)Sql.Func<T>(...)OnRaw(...)
Examples:
Sql.Raw<int>("ROW_NUMBER() OVER(ORDER BY u.\"id_user\")")
Sql.Func<string>("CUSTOM_FUNC", u.Col(x => x.Code), Sql.Val(5))
Use these when you need provider-specific or advanced SQL fragments, but keep normal query construction on typed helpers where possible.
Scoped Subqueries
Two subquery helper families exist:
- classic typed helper:
Sql.SubQuery<TModel, TResult>(...)Sql.Exists<TModel>(...)
- scoped typed helper:
Sql.SubQueryScope<TModel, TResult>(...)Sql.ExistsScope<TModel>(...)
Use the scoped helpers when the subquery itself benefits from FromTypedScope(...).
Example:
.SelectSql(u => Sql.SubQueryScope<User, string>(
db,
sq => sq
.Join<UserClient>(j => j.Left().As("c").OnSql((x, c) =>
x.Col(a => a.Id).Eq(c.Col(a => a.UserId))))
.Join<UserRole>(j => j.Left().As("r").OnSql((x, c, r) =>
c.Col(a => a.UserId).Eq(r.Col(a => a.UserId))))
.SelectSql((x, c, r) => r.Col(a => a.RoleName)),
"x").As("role_name"))
ExistsScope(...) works the same way, but returns a predicate.
Modify Builders
Typed SQL DSL also exists for modify builders:
InsertTyped<T>()UpdateTyped<T>()DeleteTyped<T>()
Examples:
db.UpdateTyped<User>()
.SetSql(u => u.Code, u => Sql.Coalesce<string>(Sql.Val("900"), u.Col(x => x.Code)))
.WhereSql(u => u.Col(x => x.Id).Eq(1));
db.InsertTyped<User>()
.InsertSql(u => u.Code, u => Sql.Val("901"));
db.DeleteTyped<User>()
.WhereSql(u => u.Col(x => x.Id).Eq(2));
Practical Guidance
- prefer
FromTypedScope(...)once a query has more than one or two joins - use
OnSql(...)instead ofOnRaw(...)whenever mappings exist - use
Sql.Func(...)andSql.Raw(...)sparingly as escape hatches - use
SubQueryScope(...)/ExistsScope(...)when the subquery itself is multi-join and typed - keep the original dynamic fluent builder for cases where fully dynamic alias/member construction is still the better fit