JsonQueryToLinq 1.0.0
dotnet add package JsonQueryToLinq --version 1.0.0
NuGet\Install-Package JsonQueryToLinq -Version 1.0.0
<PackageReference Include="JsonQueryToLinq" Version="1.0.0" />
<PackageVersion Include="JsonQueryToLinq" Version="1.0.0" />
<PackageReference Include="JsonQueryToLinq" />
paket add JsonQueryToLinq --version 1.0.0
#r "nuget: JsonQueryToLinq, 1.0.0"
#:package JsonQueryToLinq@1.0.0
#addin nuget:?package=JsonQueryToLinq&version=1.0.0
#tool nuget:?package=JsonQueryToLinq&version=1.0.0
JsonQueryToLinq
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:
ExpandoObjectand any otherIDynamicMetaObjectProviderIDictionary<string, object>,Dictionary<string, TValue>and non-genericIDictionaryJsonElement/JsonDocument.RootElementandJsonNode/JsonObjectobject-typed properties and dictionary values, including plain classes reached through them ("Owner.City"whereOwnerholds 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
25matches a storedint,long,decimalorJsonElement25 — 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). operatoris optional and defaults to"and". Aliases:and/&&,or/||.keymatches properties case-insensitively and supports dot-separated nested paths ("Address.City", even"Name.Length").- An empty
rulesarray 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 anintproperty,"true"for abool. String-to-number parsing follows the target type's invariant parsing rules (so"1e3"works fordoublebut not forint). JSON objects and arrays never coerce to strings. - Dates parse from ISO-8601 strings (
"2024-01-15","2024-01-15T10:30:00Z"), includingDateOnly/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 JSONnull; convertingnullto 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 (
neqis the exception, see below) treat unreachable values as not matching the positive form:notcontainsandnotinare true when the string, the path, or the value is null. isnull/isemptytreat a broken path as null:Address.Cityis null whenAddressitself is null.eqwith anullvalue requires the path to be intact:Address.City eq nullmatches a non-nullAddresswhoseCityis null (useisnullfor "either is null").neqcompares directly:x.Name != "x"is true for a nullName, 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) →LIKEstring.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 belowAddress, and"*"allows any key — pair that withDeniedKeysto allow everything but a few fields. A policy fails closed: an emptyAllowedKeysrejects every key.Name mapping.
KeyMapdecouples 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,MaxValueCountandMaxValueLengthbound what a single request can cost.JsonQueryPolicy.Restrictivecarries sensible values (and no fields — combine it as above). Limits are opt-in:JsonQuery.Parseon 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
StringComparisonoption (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 | Versions 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. |
-
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 |