Skip to content

Validation

AmirHosseinMp02 edited this page Aug 1, 2026 · 1 revision

Validation

A Query that arrived from outside your API describes what a caller wants. Validation is where you state what a caller is allowed to want. It is a policy layer you configure per endpoint, and it is independent of the structural protections QueryForge applies automatically (see Security).


The two modes

public enum QueryValidationMode { SilentStrip, ThrowException }
Mode Behaviour Use for
SilentStrip Removes what is not allowed, clamps what is out of range, and lets the request succeed. Public APIs. A client that asks for one column too many still gets its page.
ThrowException Collects every violation and throws QueryValidationException. Internal APIs and strict environments, where a bad request should fail loudly.

SilentStrip is the default parameter value.

Both modes run every rule before returning — ThrowException does not stop at the first violation, so InvalidProperties lists all of them.


Calling it

query.Validate(rules =>
{
    rules.PageSize(p => p.Max(100).Min(1));
    rules.Select(c   => c.Deny("PasswordHash", "SecretKey"));
    rules.Where(c    => c.Deny("PasswordHash", "SecretKey"));
    rules.Sort(c     => c.Allow("Score", "CreatedOn", "LastName"));
    rules.GroupBy(c  => c.Allow("Country", "Department", "Role"));
}, QueryValidationMode.SilentStrip);

Validate returns the same Query instance so it can be chained, and mutates it in place in SilentStrip mode. That is why Query is a mutable class rather than a record.

There is also an overload taking a pre-built QueryValidationRules, which is how you share one policy across endpoints:

static readonly QueryValidationRules PublicPolicy = new QueryValidationRules()
    .PageSize(p => p.Max(100))
    .Select(c => c.Deny("PasswordHash"))
    .Where(c  => c.Deny("PasswordHash"));

query.Validate(PublicPolicy, QueryValidationMode.SilentStrip);

QueryValidationRules holds no per-request state, so a single static instance is safe to share across requests and threads.


The rule surface

public class QueryValidationRules
{
    public QueryValidationRules PageSize(Action<QueryPagingRuleBuilder> configure);
    public QueryValidationRules Select(Action<QueryColumnRuleBuilder> configure);   // SelectColumns
    public QueryValidationRules Sort(Action<QueryColumnRuleBuilder> configure);     // SortColumns
    public QueryValidationRules GroupBy(Action<QueryColumnRuleBuilder> configure);  // GroupByColumns
    public QueryValidationRules Where(Action<QueryColumnRuleBuilder> configure);    // Criteria conditions
}

public class QueryColumnRuleBuilder
{
    public QueryColumnRuleBuilder Allow(params string[] columns);
    public QueryColumnRuleBuilder Deny(params string[] columns);
}

public class QueryPagingRuleBuilder
{
    public QueryPagingRuleBuilder Max(int max);
    public QueryPagingRuleBuilder Min(int min);
}

The five column rule sets are independent. Denying a column in Select does not deny it in Where — a caller could still filter on it and infer its values. To seal a column off completely, deny it in all four places:

rules.Select(c  => c.Deny("PasswordHash"));
rules.Where(c   => c.Deny("PasswordHash"));
rules.Sort(c    => c.Deny("PasswordHash"));
rules.GroupBy(c => c.Deny("PasswordHash"));

This separation is deliberate — it is what lets you allow filtering on a column you never return, and vice versa — but it does mean a "hide this column" policy has to be written out four times.

Allow vs. Deny

Configuration Effect
neither everything is allowed
Allow(…) only only those columns are allowed
Deny(…) only everything except those
both must be in Allow and not in Deny — deny wins

Both lists are case-insensitive.

An empty allow-list means "no allow-list", not "allow nothing". If you want to reject everything, deny what exists, or simply do not expose the endpoint.

Page size

Rule SilentStrip ThrowException
Max(n) and Size > n Size is clamped to n error "Paging.Size > n"
Min(n) and Size < n Size is raised to n error "Paging.Size < n"

Min is evaluated against the value Max may already have clamped. Paging.Number is not validated — a page past the end is harmless and returns an empty page.

Note that a Size of 0 or less never reaches the provider as-is: it is normalized to the default of 12 at execution time. If you set Min(1), a zero size is raised to 1 by validation instead.


Exactly what SilentStrip does

Target Behaviour when a column is not allowed
SelectColumns the entry is removed
SortColumns the descriptor is removed
GroupByColumns the descriptor is removed
Criteria conditions the condition is removed from its group
a group left with zero conditions the whole group is dropped
Paging.Size clamped into range

Because the collections are replaced rather than edited, the original QueryCriteria instance a caller may still hold is not mutated — the query's Criteria property is pointed at a new record.

Stripping every condition from every group leaves an empty criteria, which matches everything. If your policy is "this endpoint requires a tenant filter", enforce it by composing the restriction server-side rather than by relying on the client to send it. See Recipes.

Exactly what ThrowException does

public class QueryValidationException : Exception
{
    public IReadOnlyList<string> InvalidProperties { get; }
}
  • Message: "Query validation failed. See InvalidProperties for details."
  • InvalidProperties contains one entry per violation:
    • the column name for a select, sort, group-by or condition violation;
    • "Paging.Size > 100" / "Paging.Size < 5" for a paging violation;
    • "Object.Name is required.", "Object.Schema 'x' is not allowed." or "Object.Name 'x' is not allowed." for a Dapper object violation.
  • A column violating two rules appears twice — once per rule set.

Turning that into a 400:

try
{
    query.Validate(Policy, QueryValidationMode.ThrowException);
    return Results.Ok(await svc.QueryAsync<User>(dq));
}
catch (QueryValidationException ex)
{
    return Results.ValidationProblem(
        ex.InvalidProperties
          .Distinct()
          .ToDictionary(p => p, p => new[] { "Denied by security policy" }));
}

InvalidProperties names the columns a caller asked for. If your threat model treats the existence of a column as sensitive, return a generic message rather than echoing the list.


Dapper-specific rules

The Dapper provider extends the rule set with the target object. This is a separate extension method on DapperQuery, and it runs the base rules too — call it instead of, not as well as, the base one.

using PepperX.QueryForge.Dapper;

dapperQuery.Validate(rules =>
{
    rules.Object(o => o
        .AllowSchema("dbo", "reporting")
        .DenySchema("sys")
        .AllowTable("Users", "Orders", "vw_ActiveUsers")
        .DenyTable("AuditLog")
        .RequireName());

    rules.PageSize(p => p.Max(100));
    rules.Select(c => c.Deny("PasswordHash"));
}, QueryValidationMode.SilentStrip);
public class DapperObjectRuleBuilder
{
    public DapperObjectRuleBuilder AllowSchema(params string[] schemas);
    public DapperObjectRuleBuilder DenySchema(params string[] schemas);
    public DapperObjectRuleBuilder AllowTable(params string[] tables);
    public DapperObjectRuleBuilder DenyTable(params string[] tables);
    public DapperObjectRuleBuilder RequireName(bool require = true);   // default: true
}

What each does in each mode

Situation SilentStrip ThrowException
Object is null or Name is blank, and RequireName is on nothing — it cannot be repaired error "Object.Name is required."
Schema not allowed / denied Schema is reset to "", falling back to the dialect default error "Object.Schema 'x' is not allowed."
Name not allowed / denied nothing — removing the name would break the query error "Object.Name 'x' is not allowed."

Two of those three cannot be silently repaired, which is worth internalizing:

SilentStrip cannot enforce an object policy. A denied table name stays, and a missing name stays missing — the query then fails at execution with an InvalidOperationException instead. If you are validating the target object at all, use ThrowException.

In practice the object rules matter only when the target is derived from input. If you set ForObject(...) from a constant in your own code — the recommended pattern — the target is already under your control and these rules are belt-and-braces.


Where validation sits in the pipeline

client JSON ──► Query ──► Validate() ──► provider ──► whitelist check ──► SQL / expression tree
                             ▲                              ▲
                     your policy: what a               structural: what the
                     caller may ask for                target actually exposes

The two are complementary and neither replaces the other:

  • The whitelist is automatic and cannot be switched off. It stops a caller naming a column that does not exist — the schema-probing defence.
  • Validation is yours to configure. It stops a caller naming a column that does exist but that they should not reach.

A column you never deny is reachable by any caller who knows its name, as long as it is really on the target. That is the correct default for an internal API and the wrong one for a public endpoint — which is why validation exists.


Practical policies

A public list endpoint

static readonly QueryValidationRules Public = new QueryValidationRules()
    .PageSize(p => p.Max(100))
    .Select(c  => c.Allow("UserId", "FirstName", "LastName", "Country", "Score"))
    .Where(c   => c.Allow("Country", "Department", "Score", "IsActive", "CreatedOn"))
    .Sort(c    => c.Allow("LastName", "Score", "CreatedOn"))
    .GroupBy(c => c.Allow("Country", "Department"));

Allow-lists rather than deny-lists, so a column added to the table later is not automatically exposed. This is the safer default for anything client-facing.

An internal reporting endpoint

static readonly QueryValidationRules Internal = new QueryValidationRules()
    .PageSize(p => p.Max(5000))
    .Select(c => c.Deny("PasswordHash", "SecurityStamp"))
    .Where(c  => c.Deny("PasswordHash", "SecurityStamp"));

Deny-lists, larger pages, and ThrowException so a mistake in an internal caller is loud.

Per-role policies

var rules = user.IsInRole("Admin") ? Internal : Public;

query.Validate(rules, user.IsInRole("Admin")
    ? QueryValidationMode.ThrowException
    : QueryValidationMode.SilentStrip);

Testing a policy

Validation is a pure function over a Query, so it needs no database:

[Fact]
public void Denied_column_is_stripped()
{
    var query = QueryBuilder.Select("UserId", "PasswordHash").Build();

    query.Validate(r => r.Select(c => c.Deny("PasswordHash")), QueryValidationMode.SilentStrip);

    Assert.Equal(["UserId"], query.SelectColumns);
}

[Fact]
public void Denied_column_throws_and_is_reported()
{
    var query = QueryBuilder.Select("UserId", "PasswordHash").Build();

    var ex = Assert.Throws<QueryValidationException>(() =>
        query.Validate(r => r.Select(c => c.Deny("PasswordHash")), QueryValidationMode.ThrowException));

    Assert.Contains("PasswordHash", ex.InvalidProperties);
}

The repository's own suites cover both modes across all five rule sets; see Testing.

Clone this wiki locally