JsonQueryToLinq 1.0.0

dotnet add package JsonQueryToLinq --version 1.0.0
                    
NuGet\Install-Package JsonQueryToLinq -Version 1.0.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="JsonQueryToLinq" Version="1.0.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="JsonQueryToLinq" Version="1.0.0" />
                    
Directory.Packages.props
<PackageReference Include="JsonQueryToLinq" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add JsonQueryToLinq --version 1.0.0
                    
#r "nuget: JsonQueryToLinq, 1.0.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package JsonQueryToLinq@1.0.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=JsonQueryToLinq&version=1.0.0
                    
Install as a Cake Addin
#tool nuget:?package=JsonQueryToLinq&version=1.0.0
                    
Install as a Cake Tool

JsonQueryToLinq

NuGet CI

Converts JSON filter queries — rules of key / condition / value combined with nested and / or groups — into LINQ Expression<Func<T, bool>> predicates. The generated expressions work with EF Core IQueryable providers (they translate to SQL) and, via Compile(), with plain in-memory collections.

  • Zero runtime dependencies (uses the framework's System.Text.Json)
  • Targets .NET 10
  • MIT licensed

Installation

dotnet add package JsonQueryToLinq

Quick start

using JsonQueryToLinq;

var json = """
{
  "operator": "and",
  "rules": [
    { "key": "Name", "condition": "contains", "value": "ali" },
    {
      "operator": "or",
      "rules": [
        { "key": "Age", "condition": "gt", "value": 18 },
        { "key": "IsVip", "condition": "eq", "value": true }
      ]
    }
  ]
}
""";

// EF Core: the filter translates to SQL.
var people = await db.People.WhereJson(json).ToListAsync();

// In-memory collections work the same way.
var filtered = peopleList.WhereJson(json).ToList();

For more control, parse and build separately:

QueryGroup query = JsonQuery.Parse(json);                     // validate once, reuse
Expression<Func<Person, bool>> expr = query.ToExpression<Person>();
Func<Person, bool> predicate = query.ToPredicate<Person>();   // compiled delegate

// Or in one step:
var expr2 = JsonQuery.BuildExpression<Person>(json);

// To test single objects (see below):
var matcher = JsonQuery.CreateMatcher<Person>(json);

QueryNode / QueryGroup deserialize with System.Text.Json, so a query can also be a property of your own request DTO:

public sealed record SearchRequest(int Page, QueryGroup? Filter);

Testing a single object

Queries are not only for collections — a JsonQueryMatcher<T> evaluates one object at a time, which is what you want inside an if:

var isEligible = new JsonQueryMatcher<Person>(json);   // parses and compiles once

if (isEligible.Matches(person))
{
    // ...
}

Build the matcher once (it is immutable and thread-safe) and reuse it: the constructor does the parsing and compilation, Matches is just a delegate call. It also converts implicitly to Func<T, bool> and Expression<Func<T, bool>>, so the same instance can drive a LINQ query:

var eligible = people.Where(isEligible);          // implicit Func<Person, bool>
var fromDb = db.People.Where(isEligible);         // implicit Expression<Func<Person, bool>>

If you already hold a parsed query, Matches works directly on it and caches the compiled predicate per query and type, so calling it in a loop does not recompile anything:

QueryGroup query = JsonQuery.Parse(json);

foreach (var person in people)
{
    if (query.Matches(person)) { /* ... */ }
}

For a genuinely one-off check there is JsonQuery.Matches(json, person) — it parses and compiles on every call, so prefer a matcher for anything repeated. Matching a null object throws ArgumentNullException; a null property or a null step in a nested path simply does not match (see Null semantics).

Dynamic objects

The target does not have to be a class with known properties. ExpandoObject, dictionaries and the two JSON DOMs work the same way, with members resolved at run time:

dynamic person = new ExpandoObject();
person.Name = "Ali";
person.Age = 30;

if (JsonQuery.Matches("""{"key":"Age","condition":"gte","value":18}""", person))
{
    // ...
}

var matcher = new JsonQueryMatcher<ExpandoObject>(json);   // or <object>, <dynamic>
var adults = records.WhereJson(json);                      // IEnumerable<dynamic> works too

Supported dynamic targets:

  • ExpandoObject and any other IDynamicMetaObjectProvider
  • IDictionary<string, object>, Dictionary<string, TValue> and non-generic IDictionary
  • JsonElement / JsonDocument.RootElement and JsonNode / JsonObject
  • object-typed properties and dictionary values, including plain classes reached through them ("Owner.City" where Owner holds a POCO), and any mix of the above along one path

Because each object may have its own shape, the data side is forgiving: a member that is absent, null, or holds a different type than the rule implies simply does not match — no exception. So isnull, isempty, notcontains and notin are true for a missing member, and eq, gt, contains are false. Query-side mistakes still fail fast: an invalid condition or an unusable value throws JsonQueryException while the matcher is being built.

Two more details worth knowing:

  • Member names match case-insensitively, exact match first.
  • Numbers compare across CLR types, so a JSON 25 matches a stored int, long, decimal or JsonElement 25 — which is what makes objects deserialized from JSON behave as expected.

Dynamic matching runs in memory only: the emitted expression calls back into the library, so EF Core cannot translate it to SQL. Use typed entities for database queries.

JSON schema

A query node is either a group or a rule:

// Group: combines child nodes with one logical operator. Groups nest arbitrarily.
{ "operator": "and" | "or", "rules": [ <node>, ... ] }

// Rule: tests a single property.
{ "key": "PropertyName", "condition": "eq", "value": <json value> }
  • The root may be a group or a single bare rule (wrapped in an implicit and).
  • operator is optional and defaults to "and". Aliases: and/&&, or/||.
  • key matches properties case-insensitively and supports dot-separated nested paths ("Address.City", even "Name.Length").
  • An empty rules array matches everything (a no-op filter).
  • Keys may be qualified with the name of the root object ("person.age") — see Root-qualified keys.
  • JSON property names (key, condition, value, operator, rules) are matched case-insensitively.

Root-qualified keys

Query builders often qualify every field with the name of the thing being filtered:

{ "key": "person.age", "condition": "gte", "value": 18 }

When the leading segment names the queried type itself, it is recognized as the root and skipped, so this query works against Person with no configuration (matching is case-insensitive, and a real property of that name always wins over the root rule).

When the root is named something else — a different type name, or a dynamic target such as ExpandoObject — tell the parser what the prefix is:

var query = JsonQuery.Parse(json, rootAlias: "person");   // "person.age" becomes "age"

var adults = records.WhereJson(query);
if (query.Matches(record)) { /* ... */ }

The alias is stripped from every key in the tree, is matched case-insensitively, leaves keys that don't start with it alone, and may itself span segments (rootAlias: "data.person"). On a typed target an unrecognized leading segment still throws JsonQueryException, and the message points at this option; on a dynamic target it is just an absent member, which does not match.

Conditions

Condition aliases are case-insensitive; underscores, hyphens and spaces are ignored ("Not_Contains" ≡ "notcontains").

Condition Aliases Meaning
eq equal, equals, =, == property == value
neq ne, notequal, notequals, !=, <> property != value
gt greaterthan, > property > value
gte ge, greaterthanorequal, >= property >= value
lt lessthan, < property < value
lte le, lessthanorequal, <= property ⇐ value
contains like string contains value
notcontains notlike string does not contain value (true for null strings)
startswith beginswith string starts with value
endswith — string ends with value
in — property equals one of the array's values; [] matches nothing
notin — property equals none of the array's values; [] matches everything
isnull null property is null (no value needed)
isnotnull notnull property is not null (no value needed)
isempty empty string is null or "" (no value needed)
isnotempty notempty string is neither null nor "" (no value needed)

Relational conditions (gt/gte/lt/lte) support numeric types, char, DateTime, DateTimeOffset, DateOnly, TimeOnly, TimeSpan and enums. String-only conditions (contains, startswith, endswith, isempty, …) require a string property.

Value conversion

The JSON value is converted to the CLR type of the target property when the expression is built, always with the invariant culture:

  • Numbers, booleans and strings convert leniently — "42" works for an int property, "true" for a bool. String-to-number parsing follows the target type's invariant parsing rules (so "1e3" works for double but not for int). JSON objects and arrays never coerce to strings.
  • Dates parse from ISO-8601 strings ("2024-01-15", "2024-01-15T10:30:00Z"), including DateOnly/TimeOnly.
  • Enums accept member names case-insensitively ("active") or their numeric values (2). Names or numbers that don't identify a defined member are rejected (except for [Flags] enums, which accept combinations).
  • Nullable<T> properties accept JSON null; converting null to a non-nullable property type is an error.

Null semantics

Generated predicates never throw NullReferenceException on null values or broken paths:

  • String conditions guard the target: x.Name != null && x.Name.Contains("ali").
  • Nested paths guard every step: x.Address != null && x.Address.City == "Ankara".
  • Negated conditions (neq is the exception, see below) treat unreachable values as not matching the positive form: notcontains and notin are true when the string, the path, or the value is null.
  • isnull / isempty treat a broken path as null: Address.City is null when Address itself is null.
  • eq with a null value requires the path to be intact: Address.City eq null matches a non-null Address whose City is null (use isnull for "either is null").
  • neq compares directly: x.Name != "x" is true for a null Name, but a broken nested path does not match.

Case sensitivity of string matching

String conditions emit the single-argument string.Contains / StartsWith / EndsWith overloads — the only ones EF Core translates. In memory that comparison is ordinal (case-sensitive); in the database the column's collation decides (often case-insensitive). If you need identical behavior in both worlds, normalize your data or configure the collation accordingly.

EF Core translatability

Only constructs with well-known SQL translations are emitted:

  • property access chains, lifted binary operators (==, !=, <, …)
  • string.Contains / StartsWith / EndsWith (single argument) → LIKE
  • string.IsNullOrEmpty → IS NULL OR = ''
  • List<T>.Contains → IN (...)
  • constants are wrapped in a closure so EF Core parameterizes them instead of inlining SQL literals (better plan caching, no injection surface)

Note: relational databases use three-valued NULL logic; EF Core compensates by default so results match the in-memory semantics described above in the common cases. DateOnly, TimeOnly and TimeSpan comparison translation depends on your provider (SQLite in particular does not translate TimeSpan comparisons).

Wide groups are combined as a balanced tree, so even queries with thousands of rules do not overflow the stack of recursive expression visitors.

Securing client queries

A query decides which fields it touches, so a query from a client must be constrained. With no policy, {"key":"PasswordHash","condition":"startswith","value":"a"} is a valid filter: whether it returns rows tells the caller whether the hash starts with a, and repeating that reads the secret one character at a time. Anything reachable from your entity — including navigation properties — is fair game, and nothing bounds the size of the query.

JsonQueryPolicy closes that. It validates a query and returns a sanitized copy, so the rest of your code is unchanged:

static readonly JsonQueryPolicy PeopleFilter = JsonQueryPolicy.Restrictive with
{
    AllowedKeys = ["Name", "Age", "Address.*"],
};

var query = PeopleFilter.Parse(json);          // throws JsonQueryException if it violates the policy
var people = await db.People.WhereJson(query).ToListAsync();
  • Allow-list. Only the listed keys are queryable; matching is case-insensitive. "Address.*" covers everything below Address, and "*" allows any key — pair that with DeniedKeys to allow everything but a few fields. A policy fails closed: an empty AllowedKeys rejects every key.

  • Name mapping. KeyMap decouples the wire contract from the model — ["full_name"] = "Name", ["city"] = "Address.City". A whole key is mapped first, then its first segment, so ["addr"] = "Address" turns "addr.city" into "Address.city". The allow-list is checked against the mapped key.

  • Limits. MaxRules, MaxDepth, MaxPathSegments, MaxValueCount and MaxValueLength bound what a single request can cost. JsonQueryPolicy.Restrictive carries sensible values (and no fields — combine it as above). Limits are opt-in: JsonQuery.Parse on its own stays permissive for queries you generate yourself.

  • Model-declared surface. JsonQueryPolicy.For<T>() builds the policy from [JsonQueryable] attributes, so the queryable fields live next to the model:

    public sealed class Account
    {
        [JsonQueryable(Name = "user_name")] public string Login { get; set; }
        [JsonQueryable] public Profile Profile { get; set; }   // "Profile.City" if City is marked too
        public string PasswordHash { get; set; }               // unmarked: never queryable
    }
    
  • No schema disclosure. A blocked field and a field that does not exist produce the identical message — 'PasswordHash' is not a queryable field. — with no type name and no hint about what exists, so the endpoint cannot be used to map your model.

Call policy.EnsureResolvable<T>() once at startup: it verifies that every allowed key really resolves on T, so a typo in the allow-list is caught by you rather than by a client.

Error handling

Everything that goes wrong throws JsonQueryException with a descriptive message: malformed JSON, an unknown condition/operator, a missing or mistyped value, an unknown property key, a condition unsupported for the property type, or a value that cannot be converted.

try
{
    return Ok(await db.People.WhereJson(PeopleFilter.Parse(json)).ToListAsync());
}
catch (JsonQueryException ex)
{
    return BadRequest(ex.Message);
}

Returning ex.Message is safe for messages a policy produces — they are written not to disclose anything. Messages from the layer underneath are written for developers and do name properties and types (Property 'Salary' was not found on type 'Person'), which is exactly what you want while building a query yourself and exactly what you do not want to hand to an anonymous caller. So: parse client input through a policy, and if you skip the policy, log the message and return a generic 400 instead of echoing it.

Limitations & roadmap

  • Filtering only — sorting (OrderBy) and paging (Skip/Take) are planned.
  • A policy bounds which fields and how many conditions a query may use, but not what a permitted query costs the database; keep the usual query timeouts and indexes in place.
  • No collection navigation (Orders.Any(...)) yet.
  • No StringComparison option (kept out to preserve EF Core translatability).
  • Nested paths through nullable structs (Nullable<TStruct>.Field) are not supported.
  • Fields are not supported; only public instance properties (and dictionary/DOM members on dynamic targets).

License

MIT © İsmail Özçelik

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • net10.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.

Version Downloads Last Updated
1.0.0 142 8/5/2026