Connection filters and write rules are declared once in a FilterSet, used by a database connection and applied to
every typed API it runs, making it easy to enforce multi-tenancy, soft deletes and auditing in one place instead of in
every query:
public static readonly FilterSet<int> TenantFilters = FilterSet.Create<int>(f =>
f.Ensure<IHasTenantId>(x => x.TenantId, tenantId => tenantId));
db.UseFilters(TenantFilters.For(tenantId));
var orders = db.Select<Order>(x => x.Total > 100);
SELECT "Id", "TenantId", "CustomerId", "Total", "IsDeleted", "CreatedBy", "CreatedDate", "ModifiedBy", "ModifiedDate"
FROM "Order"
WHERE ("Order"."TenantId" = @0) AND (("Total" > @1))
-- @0 = 1, @1 = 100
A FilterSet has these rules:
| Rule | Purpose |
|---|---|
Ensure |
Rows have a value, e.g. the tenant: the rows a connection can read, update and delete, and the value it writes |
Filter |
The rows a connection can read, update and delete, e.g. rows that aren't soft deleted |
OnInsert, OnUpdate, OnWrite |
The values a connection writes, e.g. who changed a row and when |
A set is declared once for your App, and reads its values from a scope that each connection provides, e.g. the current
request's tenant and user, without static delegates like OrmLiteConfig.InsertFilter needing to resolve the current
request.
INFO
Building a multi-tenant App? Multitenancy shows how these APIs fit together, and the Multitenancy Guide how a complete SaaS App uses them.
Example Data Model​
The examples on this page use these tables:
public interface IHasTenantId
{
int TenantId { get; }
}
public interface IAudit
{
string CreatedBy { get; }
DateTime CreatedDate { get; }
string ModifiedBy { get; }
DateTime ModifiedDate { get; }
}
public class Customer : IHasTenantId
{
public int Id { get; set; }
public int TenantId { get; set; }
public string Name { get; set; }
}
public class Order : IHasTenantId, IAudit
{
[AutoIncrement]
public int Id { get; set; }
public int TenantId { get; set; }
public int CustomerId { get; set; }
public decimal Total { get; set; }
public bool IsDeleted { get; set; }
[IgnoreOnUpdate] // only set when the row is inserted
public string CreatedBy { get; set; }
[IgnoreOnUpdate]
public DateTime CreatedDate { get; set; }
public string ModifiedBy { get; set; }
public DateTime ModifiedDate { get; set; }
}
Declaring filters and rules​
Declare the filters and rules of your App once in a FilterSet, with the type of the scope they read their values
from, e.g. the tenant and user a connection is used for:
public record TenantUser(int TenantId, string UserId);
public static class TenantDb
{
public static readonly FilterSet<TenantUser> UserRules = FilterSet.Create<TenantUser>(f => {
// The tenant's rows are the only rows it can read or change, and the rows it writes are the tenant's
f.Ensure<IHasTenantId>(x => x.TenantId, s => s.TenantId);
// Record who changed a row and when
f.OnInsert<IAudit>(x => x.CreatedBy, s => s.UserId);
f.OnInsert<IAudit>(x => x.CreatedDate, _ => DateTime.UtcNow);
f.OnWrite<IAudit>(x => x.ModifiedBy, s => s.UserId);
f.OnWrite<IAudit>(x => x.ModifiedDate, _ => DateTime.UtcNow);
});
public static IDbConnection ForUser(this IDbConnection db, int tenantId, string userId) =>
db.UseFilters(UserRules.For(new TenantUser(tenantId, userId)));
}
using var db = dbFactory.Open().ForUser(tenantId, userId);
set.For(scope) gives the set the scope it reads its values from, and db.UseFilters() applies it to the connection.
The scope is typed, so using a set with the wrong type of scope doesn't compile.
- The type of a rule is a table, an interface or a base class. Rules on an interface or base class apply to every table implementing or inheriting it, using the table's property of the same name, so column aliases and naming strategies apply.
- A connection can use multiple sets, and their filters and rules combine, e.g. a tenant set and a soft delete set.
- They only apply to the connection that uses them, other connections aren't affected.
- They can only be used by connections opened by OrmLite, others throw a
NotSupportedException, so they're never silently ignored.
Values are read from the scope​
Rules read their values from the scope each time they're used, i.e. for each statement and each row written, so a scope can change while the connection is open, e.g. when a request's tenant is resolved after its connection opens:
public class TenantScope
{
public int? TenantId { get; set; }
public int AssertTenantId() => TenantId ?? throw new InvalidOperationException("No tenant");
}
public static readonly FilterSet<TenantScope> TenantFilters = FilterSet.Create<TenantScope>(f =>
f.Ensure<IHasTenantId>(x => x.TenantId, s => s.AssertTenantId()));
A filter's condition and an Ensure value can only read values from the scope, so a set is the same for every
connection that uses it. Using a variable from outside the rule throws an ArgumentException when the set is created:
// ArgumentException: FilterSet rules can't use the variable 'tenantId' from outside the rule
FilterSet.Create<TenantScope>(f => f.Filter<IHasTenantId>((x, s) => x.TenantId == tenantId));
Sets without a scope​
Sets whose rules don't change, e.g. soft deletes, can be created without a scope:
public static readonly FilterSet SoftDeletes = FilterSet.Create(f =>
f.Filter<Order>(x => !x.IsDeleted));
db.UseFilters(SoftDeletes);
Using a set more than once​
It's safe to configure a connection more than once, e.g. a shared SQLite :memory: connection that's opened multiple
times in a request:
| Used again | Result |
|---|---|
With the same scope, the same object or one that's equal to it, e.g. a record with the same values |
Ignored |
| With a different scope, e.g. another tenant | Throws an InvalidOperationException, as rows would need to match both |
Translated to SQL once​
A filter's SQL is generated the first time it's used, then reused by every connection that uses its set. Each statement only reads the filter's values from the scope and adds them as db params, so filters add little to a query compared to writing their condition in it.
Everything in a rule that doesn't read the row is still evaluated for each statement, including values that don't
come from the scope, e.g. DateTime.UtcNow. A null value has SQL of its own, e.g. IS NULL, so it gets its own
statement too.
A collection that has a db param for each of its values, e.g. s.ProjectIds.Contains(x.ProjectId), has SQL for each
size of the collection. Filters whose SQL changes with their values are translated for each statement instead, e.g.
x.Name.StartsWith(s.Prefix), whose LIKE has an ESCAPE when the text has wildcards. NotCachedReasons says which
filters of a set weren't reused and why, which a test can check after using the set's filters:
Assert.That(TenantFilters.NotCachedReasons, Is.Empty);
In ServiceStack Apps​
ServiceStack opens the connections used by Db in Services, AutoQuery, AutoQuery CRUD and other features for the
current request. Register a DbConnectionRequestFilter in your AppHost to apply your filter sets to every one of
them:
public override void Configure()
{
DbConnectionRequestFilters.Add((db, req) => {
var userId = req.GetUserId(); // the signed in user, or the user of an API Key
if (userId != null)
db.ForUser(GetTenantId(userId), userId); // ForUser() and GetTenantId() are defined by your App
});
}
| DbConnectionRequestFilters | |
|---|---|
| Called for | Each connection opened for a request, including named connections and read replicas, e.g. Db, ReadDb, Request.OpenDb(), OpenDbConnection(namedConnection) in Services, AutoQuery and AutoQuery CRUD |
| Not called for | Connections opened without a request |
| If a filter throws | The connection is disposed and the exception fails the request, e.g. an HttpError |
Override OnDbConnectionRequest(db, req) in your AppHost instead to run your own logic around the registered filters.
A filter's rules only apply to the tables of their types, so one set of filters can be used with every database of an
App. Filters that query the connection, e.g. to check the user's membership of a tenant, can check which database
it's for with its NamedConnection, which is null for the App's main database and its read replica:
DbConnectionRequestFilters.Add((db, req) => {
if (((OrmLiteConnection)db).NamedConnection == null)
db.ForRequest(req);
});
Connections your App opens itself, e.g. with dbFactory.Open() in a background job, have no filters or rules until
they use a set, e.g. with dbFactory.Open().ForUser(tenantId, userId).
Filters​
Ensure and Filter restrict the rows a connection can read, update and delete. They're added to queries as a
mandatory condition, like Ensure(), so other conditions can narrow results but never widen them:
db.ForUser(tenantId, userId);
// OR conditions only apply to the tenant's rows
var q = db.From<Order>().Where(x => x.Total > 100).Or(x => x.CustomerId == 1);
var orders = db.Select(q);
SELECT "Id", "TenantId", "CustomerId", "Total", "IsDeleted", "CreatedBy", "CreatedDate", "ModifiedBy", "ModifiedDate"
FROM "Order"
WHERE ("Order"."TenantId" = @0) AND (("Total" > @1) OR ("CustomerId" = @2))
-- @0 = 1, @1 = 100, @2 = 1
Every typed API is filtered​
Filters apply to every API where OrmLite creates the SQL, not just db.From<T>(), so a filtered connection can't
read other rows through APIs like SingleById():
var order = db.SingleById<Order>(1);
SELECT "Id", "TenantId", "CustomerId", "Total", "IsDeleted", "CreatedBy", "CreatedDate", "ModifiedBy", "ModifiedDate"
FROM "Order"
WHERE ("Order"."TenantId" = @_f0) AND ("Id" = @Id)
-- @Id = 1, @_f0 = 1
| API | How the filter is applied |
|---|---|
db.From<T>(), including sub queries, set operations and SeekAfter() |
A mandatory condition when the query is created |
Typed APIs: Select, Single, Count, Exists, Scalar, Column, Dictionary, Lookup, Where, SelectNonDefaults |
A mandatory condition on the query they create |
SQL fragments, e.g. db.Select<T>("Total > @min", new { min }) and Sql.Fmt() |
WHERE {filter} AND ({fragment}) |
By id: SingleById, SelectByIds, ExistsById, LoadSingleById, DeleteById, DeleteByIds |
AND {filter} |
| Joined tables | Added to the join's ON condition |
| WithRecursive() | Its recursive step is filtered, so it can't reach rows that don't match |
| TopPerGroup() | Rows are filtered before they're ranked |
LoadSelect, LoadSingleById and references |
Each referenced table is filtered |
| Updates and deletes, including returning APIs and UpdateFrom() | AND {filter}, so rows that don't match aren't changed |
Save and Upsert |
Their check for an existing row and its update are filtered |
All their async and lazy variants are filtered too.
Joined tables​
A joined table's filter is added to its join's ON condition instead of the WHERE, so a LEFT JOIN still returns
rows without a match:
var q = db.From<Order>()
.Join<Customer>((o, c) => o.CustomerId == c.Id)
.Where<Customer>(c => c.Name == "Acme");
var orders = db.Select(q);
SELECT "Order"."Id", "Order"."TenantId", "Order"."CustomerId", "Order"."Total", "Order"."IsDeleted",
"Order"."CreatedBy", "Order"."CreatedDate", "Order"."ModifiedBy", "Order"."ModifiedDate"
FROM "Order" INNER JOIN "Customer"
ON ("Order"."CustomerId" = "Customer"."Id") AND ("Customer"."TenantId" = @1)
WHERE ("Order"."TenantId" = @0) AND (("Customer"."Name" = @2))
-- @0 = 1, @1 = 1, @2 = 'Acme'
Soft deletes​
Filter restricts the rows a connection sees without changing what it writes. Filters on a table only apply to that
table, and combine with interface filters:
public static readonly FilterSet SoftDeletes = FilterSet.Create(f =>
f.Filter<Order>(x => !x.IsDeleted));
db.ForUser(tenantId, userId).UseFilters(SoftDeletes);
var orders = db.Select<Order>(x => x.Total > 100);
SELECT "Id", "TenantId", "CustomerId", "Total", "IsDeleted", "CreatedBy", "CreatedDate", "ModifiedBy", "ModifiedDate"
FROM "Order"
WHERE "Order"."IsDeleted"=0 AND ("Order"."TenantId" = @0) AND (("Total" > @1))
-- @0 = 1, @1 = 100
A row is soft deleted with an update, after which the connection can no longer see or change it:
db.UpdateOnly(() => new Order { IsDeleted = true }, where: x => x.Id == id);
db.SingleById<Order>(id); // null
Updates and deletes​
A filtered connection can't change rows it can't see:
db.UpdateOnly(() => new Order { Total = 200 }, where: x => x.Id == 1);
db.DeleteById<Order>(99);
UPDATE "Order" SET "Total"=@Total WHERE ("Order"."TenantId" = @0) AND (("Id" = @1))
-- @0 = 1, @1 = 1, @Total = 200
DELETE FROM "Order" WHERE "Id" = @0 AND ("Order"."TenantId" = @_f0)
-- @0 = 99, @_f0 = 1
Rows that don't match the filter are treated like rows that don't exist:
- Updates and deletes affect 0 rows, so check the returned row count to handle rows that weren't changed.
- Updates and deletes of tables with a
[RowVersion]throw anOptimisticConcurrencyException, as they do when no row is changed. SaveandUpsertdon't see the row as existing, so they insert it, which fails on its primary key instead of overwriting the row.
Filters that only apply sometimes​
A condition that only reads the scope decides whether the rest of the filter applies, e.g. admins see every tenant's rows:
public class UserAccess
{
public int TenantId { get; set; }
public bool IsAdmin { get; set; }
}
public static readonly FilterSet<UserAccess> AccessFilters = FilterSet.Create<UserAccess>(f =>
f.Filter<IHasTenantId>((x, s) => s.IsAdmin || x.TenantId == s.TenantId));
Conditions that only read the scope are decided before the filter is translated to SQL, so each case gets its own
SQL. An admin's queries have no tenant condition, and everyone else's only have "TenantId" = @0. They're evaluated
as C# would, so the rest of a condition isn't evaluated when it can't match:
// Tenant rows aren't returned until the tenant is resolved, without calling AssertTenantId()
f.Filter<IHasTenantId>((x, s) => s.TenantId != null && x.TenantId == s.AssertTenantId());
Write rules​
Write rules set the values of columns in the rows a connection inserts and updates.
Which rule to use​
Ensure |
OnInsert, OnUpdate, OnWrite |
|
|---|---|---|
| Use for | A column that's the same for everything the connection writes, e.g. TenantId |
A column recording an insert or update, e.g. CreatedBy, ModifiedDate |
| Applies to | Inserts and updates | Inserts, updates or both |
| App sets a different value | Throws, as it's a bug | Replaced with the rule's value |
| App doesn't set a value | Inserts set it, updates leave the column alone | Always set |
As a rule of thumb, use Ensure for who owns a row, and OnInsert, OnUpdate and OnWrite for who changed it and
when.
Auditing with OnInsert, OnUpdate and OnWrite​
OnInsert sets a column in rows that are inserted, OnUpdate in rows that are updated, and OnWrite in both. Their
value is read from the scope for each row:
f.OnInsert<IAudit>(x => x.CreatedBy, s => s.UserId);
f.OnInsert<IAudit>(x => x.CreatedDate, _ => DateTime.UtcNow);
f.OnWrite<IAudit>(x => x.ModifiedBy, s => s.UserId);
f.OnWrite<IAudit>(x => x.ModifiedDate, _ => DateTime.UtcNow);
They replace any value from your App, so audit columns can be trusted:
db.Insert(new Order { CustomerId = 1, Total = 100, CreatedBy = "mallory" });
INSERT INTO "Order" ("TenantId","CustomerId","Total","IsDeleted","CreatedBy","CreatedDate","ModifiedBy","ModifiedDate")
VALUES (@TenantId,@CustomerId,@Total,@IsDeleted,@CreatedBy,@CreatedDate,@ModifiedBy,@ModifiedDate)
-- @TenantId = 1, @CustomerId = 1, @Total = 100, @IsDeleted = 0,
-- @CreatedBy = 'alice', @CreatedDate = '2026-10-01 09:30:00',
-- @ModifiedBy = 'alice', @ModifiedDate = '2026-10-01 09:30:00'
Updates of only some columns also set the columns of OnUpdate and OnWrite rules:
db.UpdateOnly(() => new Order { Total = 120 }, where: x => x.Id == 1);
UPDATE "Order" SET "Total"=@Total, "ModifiedBy"=@ModifiedBy, "ModifiedDate"=@ModifiedDate
WHERE ("Order"."TenantId" = @0) AND (("Id" = @1))
-- @0 = 1, @1 = 1, @Total = 120, @ModifiedBy = 'alice', @ModifiedDate = '2026-10-01 09:30:00'
INFO
Use OnUpdate instead of OnWrite to leave the modified columns empty until a row is first updated.
WARNING
OnInsert sets a column when a row is inserted, it doesn't protect it from updates. db.Update(order) updates all
the object's columns, so an Order that wasn't read from the database would overwrite its CreatedBy.
Add [IgnoreOnUpdate] to columns that should only be set when a row is inserted.
Tenant rows with Ensure​
Ensure filters the rows a connection can see by a column's value, and requires the column to have the value in every
row it writes, so it guards both the rows that are changed and the values that are written:
f.Ensure<IHasTenantId>(x => x.TenantId, s => s.TenantId);
Inserts set the column when it isn't set, i.e. when it's null, its type's default value or an empty string, so your
App doesn't need to:
db.Insert(new Order { CustomerId = 1, Total = 100 }); // inserted with the connection's TenantId
Writing a different value throws an InvalidOperationException instead of replacing it, as it's a bug in your App:
// Order.TenantId must be '1' on this connection
db.Insert(new Order { TenantId = 2, CustomerId = 1, Total = 100 });
// Rows can't be moved to another tenant either
db.UpdateOnly(() => new Order { TenantId = 2 }, where: x => x.Id == 1);
| Write | Column isn't set | Column is set to a different value |
|---|---|---|
Inserts of objects and dictionaries, InsertOnly, BulkInsert |
Sets the value | Throws InvalidOperationException |
InsertIntoSelect |
Sets the value | Throws NotSupportedException if the column is selected |
Updates of all an object's columns: Update, UpdateAll, Save, Upsert |
Sets the value, so the update keeps it | Throws InvalidOperationException |
Updates of some columns: UpdateOnly, UpdateOnlyFields, UpdateNonDefaults, UpdateAdd and updates with dictionaries or anonymous objects |
Not updated | Throws InvalidOperationException |
UpdateFrom |
Not updated | Throws NotSupportedException if the column is updated |
INFO
Use Ensure for tenant columns rather than a Filter, which only filters the rows a connection sees, so an insert
could still write a row for another tenant.
Which APIs apply rules​
| Rule | APIs |
|---|---|
OnInsert |
Insert, InsertAll, InsertUsingDefaults, InsertOnly, BulkInsert, InsertIntoSelect, and the inserts of Save, SaveAll, Upsert and UpsertAll |
OnUpdate |
Update, UpdateAll, UpdateOnly, UpdateOnlyFields, UpdateNonDefaults, UpdateAdd, UpdateFrom, UpdateOnlyReturning, and the updates of Save, SaveAll, Upsert and UpsertAll |
OnWrite |
Both of the above |
Ensure |
Both of the above |
All their async variants apply rules too.
Written objects have the values that were saved​
The values of rules are set on the objects you write as well as in the database, so an object that was just inserted or updated can be used or returned from your API without reading it back:
var order = new Order { CustomerId = 1, Total = 100 };
db.Insert(order);
order.TenantId; // 1
order.CreatedBy; // alice
order.CreatedDate; // 2026-10-01 09:30:00
order.Total = 120;
db.Update(order);
order.ModifiedBy; // alice
| API | The object has the values of |
|---|---|
Insert, InsertAll, InsertOnly, BulkInsert, and the inserts of Save and Upsert |
Ensure and OnInsert rules |
Update, UpdateAll, UpdateOnlyFields, UpdateNonDefaults, and the updates of Save and Upsert |
Ensure and OnUpdate rules |
- Only the values of rules are set. Other values populated by the database, like defaults and computed columns, still need Returning APIs.
- An object that's rejected by
Ensureis left as it was. - Dictionaries passed to APIs like
db.UpdateOnly<T>(Dictionary<string,object>)aren't modified. - Objects written on a connection returned by
WithoutFilters()aren't modified, as it has no rules.
Upserts​
Upsert of a table with filters or rules uses a filtered check for an existing row followed by an
insert or an update, instead of a single statement. A single statement can't filter the row it updates in every RDBMS,
or use different values for its insert and update. So OnInsert columns are only set when the row is inserted, and
OnUpdate columns when it's updated. Tables without filters or rules continue to use a single statement.
WithoutFilters​
WithoutFilters() returns the same connection without any of its filters or rules, e.g. for admin tasks or a report
across tenants:
var adminDb = db.WithoutFilters();
var orders = adminDb.Select<Order>(x => x.Total > 100);
SELECT "Id", "TenantId", "CustomerId", "Total", "IsDeleted", "CreatedBy", "CreatedDate", "ModifiedBy", "ModifiedDate"
FROM "Order"
WHERE ("Total" > @0)
-- @0 = 100
It uses the same underlying connection and transaction, so changes with and without filters can be made in the same transaction, and disposing it doesn't close the connection:
using var trans = db.OpenTransaction();
db.UpdateOnly(() => new Order { Total = 0 }); // the tenant's orders
db.WithoutFilters().Delete<Order>(x => x.IsDeleted); // every tenant's deleted orders
trans.Commit();
It's the only way to opt-out: filters and rules can't be removed from a connection or ignored in a query.
Opting out of some filters​
A connection returned by WithoutFilters() starts without any filters or rules, and can use its own sets. They only
apply to it: they aren't added to the connection it was created from, or to the next connection returned by
WithoutFilters(). Use this to opt-out of some filters and keep others, e.g. an admin connection that sees every
tenant and still records who is writing, by keeping the audit rules in their own set:
public static readonly FilterSet<string> AuditRules = FilterSet.Create<string>(f => {
f.OnInsert<IAudit>(x => x.CreatedBy, userId => userId);
f.OnWrite<IAudit>(x => x.ModifiedBy, userId => userId);
});
public static IDbConnection AcrossTenants(this IDbConnection db, string userId) =>
db.WithoutFilters().UseFilters(AuditRules.For(userId));
db.IsWithoutFilters() returns whether a connection was returned by WithoutFilters().
Connection Items​
Code that's given a connection often needs to know what it was configured with, e.g. which tenant its filters confine
it to. Keep it with the connection using SetItem(), which lasts for as long as the connection is open:
public static IDbConnection ForUser(this IDbConnection db, int tenantId, string userId)
{
var user = new TenantUser(tenantId, userId);
return db.SetItem(nameof(TenantUser), user).UseFilters(UserRules.For(user));
}
public static int GetTenantId(this IDbConnection db) => db.GetItem<TenantUser>(nameof(TenantUser))!.TenantId;
| API | |
|---|---|
db.SetItem(key, value) |
Keep a value with the connection |
db.GetItem<T>(key) |
The value, or the default value of T if there isn't one |
db.GetOrAddItem(key, () => value) |
The value, adding the value returned by the function if there isn't one |
Items are shared with the connections returned by WithoutFilters(), which are the same connection. They aren't kept
for the next time a connection is opened.
Rules read their values from the scope when they're used, so a scope kept as an item can also hold state that changes while the connection is open, e.g. the user id that's recorded:
public class AuditUser
{
public string Id { get; set; }
}
public static readonly FilterSet<AuditUser> AuditUserRules = FilterSet.Create<AuditUser>(f =>
f.OnWrite<IAudit>(x => x.ModifiedBy, s => s.Id));
var user = db.GetOrAddItem("AuditUser", () => new AuditUser { Id = userId });
db.UseFilters(AuditUserRules.For(user));
user.Id = "billing-job"; // later writes on the connection are recorded as the job
What isn't filtered​
Filters and rules apply to the typed APIs where OrmLite creates the SQL. They don't apply to:
- Complete SQL statements: OrmLite doesn't parse or change the SQL you write in APIs like
SqlList,SqlScalar,SqlColumn,ExecuteSql, or indb.Select<T>("SELECT ...")anddb.Delete<T>("DELETE ..."). SQL fragments in typed APIs, e.g.db.Select<T>("Total > @min", new { min }), are filtered. - Queries created without the connection: only queries created from the filtered connection with
db.From<T>()are filtered, not queries created from another connection or withOrmLiteConfig.DialectProvider.SqlExpression<T>(). - Legacy APIs without a table type:
[Obsolete]APIs taking complete SQL, likeScalarFmtandColumnFmt, andUpdateFmt(table, ...)andDeleteFmt(table, ...)with a table name. Other legacy APIs likeSelectFmt<T>are filtered.
Custom Dialect Providers​
Custom dialect providers inheriting OrmLiteDialectProviderBase support filters and rules. Providers overriding these
members need to update them:
IOrmLiteDialectProviderhas a newIsFullSelectStatement()method, implemented byOrmLiteDialectProviderBase.SetParameterValues()overrides need to skip the params of filter conditions, e.g.@_f0, whereOrmLiteConnectionFiltersApi.IsFilterParam(p.ParameterName)istrue.SqlExpression<T>overrides that create params, e.g.ConvertToPlaceholderAndParameter(), need to name them withNextParamName().