Files
DynamORM/docs/typed-fluent-syntax.md
2026-02-27 13:34:16 +01:00

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 column u."id_user"
  • u.Col(x => x.Code) renders mapped column u."user_code"

Core Building Blocks

The typed DSL is built around:

  • TypedTableContext<T>
  • TypedSqlExpression<T>
  • TypedSqlPredicate
  • TypedSqlOrderExpression
  • Sql.* 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 keywords
  • NoLock()
  • 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:

  • Eq
  • NotEq
  • Gt
  • Gte
  • Lt
  • Lte
  • IsNull
  • IsNotNull
  • Like
  • StartsWith
  • EndsWith
  • Contains
  • In
  • NotIn
  • Between
  • And
  • Or
  • Not

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 of OnRaw(...) whenever mappings exist
  • use Sql.Func(...) and Sql.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