npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

querykit-builder

v0.2.0

Published

This is a Typescript query builder based on working with pdevito3/QueryKit and forked from npm library fluent-querykit

Readme

querykit-builder

This is a TypeScript query builder designed to work with pdevito3/QueryKit. It provides a fluent interface for constructing filter and sort strings.

Looking for the react package? npmjs.com/../react-querykit-builder

Installation

npm install querykit-builder

Usage

Simple example

import QueryBuilder from 'querykit-builder';

const query = new QueryBuilder()
	.equals('firstName', 'John')
	.and()
	.greaterThan('age', 25)
	.build();

// firstName == "John" && age > 25

Null/undefined values are skipped, so optional filters keep chaining:

new QueryBuilder().equals('status', undefined).greaterThan('age', 25).build();
// age > 25

Typed property paths

Give the builder your entity type and property names become compile-checked dot-paths — including nested and collection paths — instead of arbitrary strings. QueryKit matches property names case-insensitively server-side, so paths from a camelCased generated API client work as-is against PascalCase C# properties.

import type { Player } from './api-client';

new QueryBuilder<Player>()
	.equals('team.club.id', clubId)   // ✓ autocompleted
	.and()
	.greaterThan('age', 18)
	.build();

new QueryBuilder<Player>().equals('team.club.oops', 1);
// type error: '"team.club.oops"' is not assignable …

new SortBuilder<Player>().desc('createdAt').build();
buildFilters<Player>({ search: (qb) => qb.contains('name', search) });

Values are checked against the type at the path, not just the path itself:

const qb = new QueryBuilder<Ticket>();
qb.equals('points', 18);              // ✓ number field
qb.equals('points', 'eighteen');      // type error
qb.equals('status', 'Open');          // literal unions survive:
qb.equals('status', 'Archived');      // type error — not 'Open' | 'Closed'
qb.greaterThan('createdAt', '2024-01-01'); // Date fields take ISO strings
qb.has('labels', 'bug');              // collections check their element type
qb.in('status', ['Open', 'Closed']);  // array values too

PathValue<T, P> is exported if you want the resolution yourself. prop() and arith() values stay unrestricted, as do object/unknown leaves.

Recursion is depth-capped at 3 segments by default (team.club.name) — entity graphs are cyclic, and the cap is also what keeps huge generated API clients fast to type-check (a worst-case fully-cyclic 6-entity client checks in ~0.5s). Use Path<T, 3> where you genuinely need deeper, or prop('a.b.c.d') for a one-off. Arrays contribute both themselves (for has/count operators) and their element paths, and Date is a leaf. Typed builders are strict on purpose: a genuinely dynamic property name goes through prop(dynamicName), which stays untyped. Omit the type parameter and everything behaves exactly as before — plain strings.

Advanced example (grouping and mix of operators)

const query = new QueryBuilder()
	.equals('status', 'active')
	.and()
	.openParen()
		.contains('description', 'urgent')
		.or()
		.greaterThan('priority', 5)
	.closeParen()
	.or()
	.openParen()
		.equals('User.Id', 5)
		.or()
		.equals('User.Id', 6)
	.closeParen()
	.build();

// status == "active" && (description @= "urgent" || priority > 5) || (User.Id == 5 || User.Id == 6)

Composing with other builders

const base = new QueryBuilder().equals('department', 'IT');
const extra = new QueryBuilder().greaterThan('salary', 50000);

const finalQuery = base.concat(extra, '&&').build();
// department == "IT" && salary > 50000

Inspecting the current query

You can read a typed token view of the current builder state:

const qb = new QueryBuilder().equals('User.Id', 5).and().contains('User.Name', 'not');
qb.getTokens();
// [
//   { type: 'condition', property: 'User.Id', operator: '==', value: 5 },
//   { type: 'logical', operator: '&&' },
//   { type: 'condition', property: 'User.Name', operator: '@=', value: 'not' },
// ]

Token types: condition, conditionArray, nullCondition, logical, paren, raw.

Parsing a query string

parseQuery turns any QueryKit filter string — from a URL, a saved view, a builder — into a real tree, and printQuery renders it back. && binds tighter than || (verified against QueryKit's own parser), and the printer parenthesizes mixed logical nesting defensively so a round-trip can never change meaning. parseQuery(printQuery(ast)) is structurally identical to ast.

import { parseQuery, printQuery, tryParseQuery } from 'querykit-builder';

parseQuery('(Title, Author.Name) @=* "king" && Rating > 4');
// { type: 'and',
//   left:  { type: 'condition', lhs: { kind: 'group', paths: ['Title', 'Author.Name'] },
//            operator: '@=*', rhs: { kind: 'string', value: 'king' } },
//   right: { type: 'condition', lhs: { kind: 'property', path: 'Rating' },
//            operator: '>', rhs: { kind: 'number', value: 4 } } }

// Unquoted values classify the way QueryKit does: null, guid, datetime,
// number, then property reference — so `FirstName == LastName` stays a
// property-to-property comparison and `CreatedAt > 2022-07-01` a date.

tryParseQuery('Age == ');       // { ok: false, error: ParseError { position: 7 } }

This is what makes an editable filter UI possible: hydrate chips from the URL, edit the tree, printQuery it back. flattenConditions gives the chip list, and mapConditions/removeConditions rewrite the tree while keeping it valid (a logical node that loses one side collapses to the other):

import { flattenConditions, removeConditions } from 'querykit-builder';

const ast = parseQuery('(A == 1 || B == 2) && C == 3');
flattenConditions(ast).length;                          // 3, in printed order
printQuery(removeConditions(ast, (_, i) => i === 0));  // B == 2 && C == 3

QueryBuilder.from uses the parser to make a builder chainable on top of an existing string:

QueryBuilder.from('Filters= Age > 21 || VIP == true')
	.and()
	.equals('Status', 'Active')
	.build();
// (Age > 21 || VIP == true) && Status == "Active"   ← top-level || protected

One tokenizer rule to know: arithmetic operators are only recognized between spaces (Price * Quantity, which is what arith() emits), so - stays usable inside dates and signed numbers.

Validating a raw query string

validateQuery now runs the real parser, so it catches everything the parser does — unknown operators, missing values, unbalanced parens, unquoted string values — and reports the character position:

import { validateQuery } from 'querykit-builder';

validateQuery('User.Id == 5 && User.Name @= "not"'); // { valid: true }
validateQuery('(Price * Quantity) > 1000'); // { valid: true }
validateQuery('User.Id == 5 User.Name @= "not"');
// { valid: false, errors: ['Unexpected "User.Name" after end of expression — … (at position 13)'] }

Operations

Every operator method takes (property, value). property accepts a string, a string array (property list grouping), a prop() reference, or an arith() expression. value accepts a literal, a prop() reference, or an arith() expression.

Comparison operators

new QueryBuilder().equals('Title', 'Dune').build();                 // Title == "Dune"
new QueryBuilder().notEquals('Title', 'Dune').build();              // Title != "Dune"
new QueryBuilder().greaterThan('Age', 25).build();                  // Age > 25
new QueryBuilder().lessThan('Age', 25).build();                     // Age < 25
new QueryBuilder().greaterThanOrEqual('Age', 25).build();           // Age >= 25
new QueryBuilder().lessThanOrEqual('Age', 25).build();              // Age <= 25

String operators

new QueryBuilder().startsWith('Title', 'Du').build();               // Title _= "Du"
new QueryBuilder().doesNotStartWith('Title', 'Du').build();         // Title !_= "Du"
new QueryBuilder().endsWith('Title', 'ne').build();                 // Title _-= "ne"
new QueryBuilder().doesNotEndWith('Title', 'ne').build();           // Title !_-= "ne"
new QueryBuilder().contains('Title', 'un').build();                 // Title @= "un"
new QueryBuilder().doesNotContain('Title', 'un').build();           // Title !@= "un"
new QueryBuilder().soundsLike('Title', 'doon').build();             // Title ~~ "doon"
new QueryBuilder().doesNotSoundLike('Title', 'doon').build();       // Title !~ "doon"

Collection operators

new QueryBuilder().has('Tags', 'sci-fi').build();                   // Tags ^$ "sci-fi"
new QueryBuilder().doesNotHave('Tags', 'sci-fi').build();           // Tags !^$ "sci-fi"
new QueryBuilder().in('Status', ['Active', 'Pending']).build();     // Status ^^ ["Active","Pending"]
new QueryBuilder().notIn('Status', ['Closed']).build();             // Status !^^ ["Closed"]

Case-insensitive operators

new QueryBuilder().equalsCaseInsensitive('Title', 'dune').build();          // Title ==* "dune"
new QueryBuilder().notEqualsCaseInsensitive('Title', 'dune').build();       // Title !=* "dune"
new QueryBuilder().startsWithCaseInsensitive('Title', 'du').build();        // Title _=* "du"
new QueryBuilder().doesNotStartWithCaseInsensitive('Title', 'du').build();  // Title !_=* "du"
new QueryBuilder().endsWithCaseInsensitive('Title', 'ne').build();          // Title _-=* "ne"
new QueryBuilder().doesNotEndWithCaseInsensitive('Title', 'ne').build();    // Title !_-=* "ne"
new QueryBuilder().containsCaseInsensitive('Title', 'un').build();          // Title @=* "un"
new QueryBuilder().doesNotContainCaseInsensitive('Title', 'un').build();    // Title !@=* "un"
new QueryBuilder().hasCaseInsensitive('Tags', 'sci-fi').build();            // Tags ^$* "sci-fi"
new QueryBuilder().doesNotHaveCaseInsensitive('Tags', 'sci-fi').build();    // Tags !^$* "sci-fi"
new QueryBuilder().inCaseInsensitive('Status', ['active']).build();         // Status ^^* ["active"]
new QueryBuilder().notInCaseInsensitive('Status', ['closed']).build();      // Status !^^* ["closed"]

Count operators

new QueryBuilder().countEquals('Tags', 3).build();                  // Tags #== 3
new QueryBuilder().countNotEquals('Tags', 3).build();               // Tags #!= 3
new QueryBuilder().countGreaterThan('Tags', 3).build();             // Tags #> 3
new QueryBuilder().countLessThan('Tags', 3).build();                // Tags #< 3
new QueryBuilder().countGreaterThanOrEqual('Tags', 3).build();      // Tags #>= 3
new QueryBuilder().countLessThanOrEqual('Tags', 3).build();         // Tags #<= 3

Property list grouping

Pass an array of properties to apply one comparison across all of them. QueryKit uses OR logic for positive operators and AND logic for negative ones.

new QueryBuilder()
	.containsCaseInsensitive(['FirstName', 'LastName', 'Email'], 'john')
	.build();
// (FirstName, LastName, Email) @=* "john"
// → matches when "john" is in FirstName OR LastName OR Email

new QueryBuilder()
	.doesNotContainCaseInsensitive(['FirstName', 'LastName'], 'test')
	.build();
// (FirstName, LastName) !@=* "test"
// → matches when "test" is in neither field

new QueryBuilder().greaterThanOrEqual(['Age', 'YearsOfExperience'], 5).build();
// (Age, YearsOfExperience) >= 5

new QueryBuilder().in(['Status', 'Type'], ['Active', 'Pending']).build();
// (Status, Type) ^^ ["Active","Pending"]

Grouping works with nested properties and combines with everything else:

new QueryBuilder()
	.containsCaseInsensitive(['Author.Name', 'Author.Email', 'Title'], search)
	.and()
	.greaterThan('Age', 25)
	.build();
// (Author.Name, Author.Email, Title) @=* "..." && Age > 25

Property-to-property comparisons

Wrap the value in prop() to compare against another property instead of a literal. Without it, a string that happens to match a property name stays quoted.

import QueryBuilder, { prop } from 'querykit-builder';

new QueryBuilder().equals('FirstName', prop('LastName')).build();
// FirstName == LastName

new QueryBuilder().equals('FirstName', 'LastName').build();
// FirstName == "LastName"   ← literal, not a property

new QueryBuilder()
	.notEquals('FirstName', prop('LastName'))
	.and()
	.greaterThan('Age', prop('Rating'))
	.build();
// FirstName != LastName && Age > Rating

Child properties work on either side:

new QueryBuilder()
	.equals('Author.Name', prop('Title'))
	.and()
	.equals('Email.Value', prop('CollectionEmail.Value'))
	.build();
// Author.Name == Title && Email.Value == CollectionEmail.Value

Arithmetic expressions

arith() builds a parenthesized expression usable as the property or the value. Parts alternate between operands and + - * / %; bare strings are property names, numbers are literals, and nested arith() calls keep their parentheses.

import QueryBuilder, { arith } from 'querykit-builder';

new QueryBuilder().greaterThan(arith('Age', '+', 'Rating'), 50).build();
// (Age + Rating) > 50

new QueryBuilder().greaterThan(arith('Price', '*', 'Quantity'), 1000).build();
// (Price * Quantity) > 1000

new QueryBuilder().equals(arith('Id', '%', 2), 0).build();
// (Id % 2) == 0

new QueryBuilder().greaterThan(arith('A', '+', 'B', '-', 'C'), 10).build();
// (A + B - C) > 10

new QueryBuilder().lessThan(arith('Total', '/', arith('Count', '+', 1)), 100).build();
// (Total / (Count + 1)) < 100

new QueryBuilder().greaterThan('Total', arith('Price', '*', 'Quantity')).build();
// Total > (Price * Quantity)

Null checks

Passing null/undefined to a normal operator is a no-op by design. Use the explicit methods when you mean "is null":

new QueryBuilder().isNull('DeletedAt').or().isNotNull('Author.Email').build();
// DeletedAt == null || Author.Email != null

new QueryBuilder().isNull(['DeletedAt', 'ArchivedAt']).build();
// (DeletedAt, ArchivedAt) == null

Logical operators, grouping and raw fragments

new QueryBuilder().equals('A', 1).and().equals('B', 2).build();     // A == 1 && B == 2
new QueryBuilder().equals('A', 1).or().equals('B', 2).build();      // A == 1 || B == 2

new QueryBuilder()
	.openParen().equals('A', 1).or().equals('B', 2).closeParen()
	.build();
// (A == 1 || B == 2)

new QueryBuilder().equals('A', 1).append('B == 2', '&&').build();   // A == 1 && B == 2
new QueryBuilder().equals('A', 1).concat(other, '&&').build();      // A == 1 && (...)
new QueryBuilder().equals('A', 1).clone();                          // independent copy

Sorting

SortBuilder produces QueryKit's sort input. Sieve syntax (-Prop for descending) is the default; verbose renders Prop asc / Prop desc.

import { SortBuilder } from 'querykit-builder';

new SortBuilder().asc('Title').desc('Age').desc('Author.Name').build();
// Title, -Age, -Author.Name

new SortBuilder().asc('Title').desc('Age').build({ style: 'verbose' });
// Title asc, Age desc

new SortBuilder({ style: 'verbose' }).sortBy('Title').build();       // Title asc
new SortBuilder({ encodeUri: true }).asc('Title').desc('Age').build(); // Title%2C%20-Age

Null/undefined properties are skipped, and entries can be managed after the fact:

const sort = new SortBuilder().asc('Title').desc('Age');

sort.clone().remove('Title').build();          // -Age
sort.clone().replace('Age', 'asc').build();    // Title, Age
sort.clone().clear().build();                  // ''
sort.getTokens();                              // [{ property: 'Title', direction: 'asc' }, ...]

Combining optional filters

buildFilters joins a keyed set of fragments and drops the falsy ones, which is usually what a filter UI needs — one entry per control, no manual and() bookkeeping. Fragments containing a top-level &&/|| are parenthesized so the join operator cannot change their meaning.

import { buildFilters } from 'querykit-builder';

const filters = buildFilters({
	search: search && ((qb) => qb.containsCaseInsensitive(['Title', 'Author.Name'], search)),
	status: status.length && ((qb) => qb.in('Status', status)),
	minAge: minAge != null && ((qb) => qb.greaterThanOrEqual('Age', minAge)),
	active: (qb) => qb.isNull('DeletedAt'),
});
// (Title, Author.Name) @=* "..." && Status ^^ [...] && Age >= 18 && DeletedAt == null
// with search = '' and status = []: Age >= 18 && DeletedAt == null

Entries can also be strings, existing builders, or a plain array; { join: '||' }, encodeUri and addFilterStatement are supported. Everything filtered out yields ''.

Reading a sort string back

import { parseSort, SortBuilder } from 'querykit-builder';

parseSort('Title, -Age');
// [{ property: 'Title', direction: 'asc' }, { property: 'Age', direction: 'desc' }]

parseSort('Title asc, Age DESC');     // same result — both syntaxes are accepted
SortBuilder.from('  -CreatedAt ,Title ').build(); // -CreatedAt, Title (normalized)

Putting it together

import QueryBuilder, { arith, prop, SortBuilder } from 'querykit-builder';

const filters = new QueryBuilder()
	.openParen()
		.containsCaseInsensitive(['Title', 'Author.Name'], search)
	.closeParen()
	.and()
	.isNull('DeletedAt')
	.and()
	.greaterThanOrEqual(arith('Score', '+', 'Bonus'), 50)
	.and()
	.notEquals('FirstName', prop('LastName'))
	.build();
// ((Title, Author.Name) @=* "...") && DeletedAt == null && (Score + Bonus) >= 50 && FirstName != LastName

const sortOrder = new SortBuilder().desc('CreatedAt').asc('Title').build();
// -CreatedAt, Title

Features

  • Fluent API for building queries
  • Full operator parity with QueryKit (comparison, string, collection, case-insensitive, count)
  • Typed property paths: QueryBuilder<Entity> compile-checks dot-paths, with prop() as the dynamic escape hatch
  • parseQuery/printQuery: a real parser producing an AST that round-trips losslessly, plus QueryBuilder.from() to chain onto existing strings
  • Property list grouping, property-to-property comparisons, arithmetic expressions and explicit null checks
  • SortBuilder for the sort input, parseSort/SortBuilder.from to read one back
  • Typed tokens via getTokens() for inspection/debugging
  • validateQuery backed by the parser, with character positions in errors
  • Logical operators (&&, ||)
  • Grouping with parentheses
  • URL encoding support (disabled by default)

Limitations / gotchas

  • The builder itself is append-only; use parseQuery → edit the AST → printQuery (or QueryBuilder.from) when you need to modify an existing query.
  • Arithmetic needs spaces around its operators (Price * Quantity); Price*Quantity reads as a single word. arith() always emits spaces.
  • Untyped builders don't schema-validate field names — use QueryBuilder<Entity> to get that at compile time.
  • Escaping is limited to quotes/backslashes; other special handling (like Unicode normalization) is caller-owned.
  • Property list grouping semantics (OR for positive operators, AND for negative ones) are applied server-side by QueryKit; the builder only renders the syntax.
  • Arithmetic and property-to-property comparisons require a QueryKit version that supports them (v1.11+).

Supported Operators

| Name | Operator | Case Insensitive Operator | Count Operator | | :--- | :--- | :--- | :--- | | Equals | == | ==* | #== | | Not Equals | != | !=* | #!= | | Greater Than | > | N/A | #> | | Less Than | < | N/A | #< | | Greater Than Or Equal | >= | N/A | #>= | | Less Than Or Equal | <= | N/A | #<= | | Starts With | = | =* | N/A | | Does Not Start With | != | !=* | N/A | | Ends With | -= | -=* | N/A | | Does Not End With | !-= | !-=* | N/A | | Contains | @= | @=* | N/A | | Does Not Contain | !@= | !@=* | N/A | | Sounds Like | ~~ | N/A | N/A | | Does Not Sound Like | !~ | N/A | N/A | | Has | ^$ | ^$* | N/A | | Does Not Have | !^$ | !^$* | N/A | | In | ^^ | ^^* | N/A | | Not In | !^^ | !^^* | N/A |

Supported Syntax

| Feature | Helper | Output | | :--- | :--- | :--- | | Property list grouping | equals(['A', 'B'], 1) | (A, B) == 1 | | Property-to-property | equals('A', prop('B')) | A == B | | Arithmetic | greaterThan(arith('A', '+', 'B'), 5) | (A + B) > 5 | | Null checks | isNull('A') / isNotNull('A') | A == null / A != null | | Sorting | new SortBuilder().asc('A').desc('B') | A, -B | | Optional filter sets | buildFilters({ a: x && (qb => …) }) | … (falsy entries dropped) | | Parsing sort input | parseSort('Title, -Age') | SortToken[] |

Todos

  1. CI/CD to simplify deployment

License

ISC