@qfilter/gridify
v0.0.1
Published
Adapter that translates @qfilter/core query results to and from Gridify filter and orderBy syntax.
Maintainers
Readme
@qfilter/gridify
Adapter that translates @qfilter/core query results to and from Gridify filter and orderBy syntax.
Build a filter with qfilter on the client, send it to a .NET API that uses Gridify, and read Gridify query strings back into qfilter.
Install
@qfilter/core is a peer dependency:
pnpm add @qfilter/gridify @qfilter/corenpm install @qfilter/gridify @qfilter/coreQuick start
import { Op, Query } from "@qfilter/core";
import { fromGridify, toGridify } from "@qfilter/gridify";
type User = {
name: string;
age: number;
active: boolean;
deletedAt: Date | null;
address: { city: string };
};
const result = new Query<User>()
.where("age", Op.GREATER_THAN_OR_EQUAL, 18)
.and()
.where("address.city", Op.EQUAL, "Madrid")
.orderBy("name", "desc")
.build();
toGridify(result);
// { filter: "age>=18,address.city=Madrid", orderBy: "name desc" }
fromGridify<User>({ filter: "age>=18,address.city=Madrid", orderBy: "name desc" });
// the same QueryResult as `result`Send the strings as query parameters:
const { filter, orderBy } = toGridify(result);
const params = new URLSearchParams({ filter, ...(orderBy && { orderBy }) });
await fetch(`/api/users?${params}`);API
toGridify(result)
function toGridify<T>(result: QueryResult<T>): GridifyQuery;Writes a QueryResult from Query.build() as Gridify strings. orderBy is left out when the result has no order. An empty query gives { filter: "" }.
fromGridify(query, options?)
function fromGridify<T>(query: GridifyQuery, options?: FromGridifyOptions<T>): QueryResult<T>;Parses Gridify strings into a QueryResult. The filter is rebuilt with a Query<T>, so it goes through core's validation. An empty or missing orderBy gives order: null.
GridifyQuery
type GridifyQuery = {
filter: string;
orderBy?: string;
};FromGridifyOptions
type FromGridifyOptions<T> = {
types?: Partial<Record<OrderBy<T>["property"], ValueType>>; // any dot-path of T
};See Value types.
Operators
Every qfilter operator has a Gridify spelling:
| Op | qfilter value | Gridify |
| -------------------------- | ----------------- | ------- |
| Op.EQUAL | "eq" | = |
| Op.NOT_EQUAL | "neq" | != |
| Op.LESS_THAN | "lt" | < |
| Op.GREATER_THAN | "gt" | > |
| Op.LESS_THAN_OR_EQUAL | "lte" | <= |
| Op.GREATER_THAN_OR_EQUAL | "gte" | >= |
| Op.CONTAINS | "contains" | =* |
| Op.NOT_CONTAINS | "notContains" | !* |
| Op.STARTS_WITH | "startsWith" | ^ |
| Op.NOT_STARTS_WITH | "notStartsWith" | !^ |
| Op.ENDS_WITH | "endsWith" | $ |
| Op.NOT_ENDS_WITH | "notEndsWith" | !$ |
Gridify's spellings differ from core's string tokens: core writes contains as *= and starts with as ^=, Gridify as =* and ^. Pass core's tokens to Query.where and Gridify's to fromGridify.
fromGridify accepts spaces around operators, so age > 18 and age>18 both parse. toGridify writes them without spaces.
Connectors and groups
| qfilter | Gridify |
| ------------ | ------- |
| and() | , |
| or() | \| |
| group(...) | ( ) |
// name = "Ana" OR (active = true AND (age < 18 OR age > 65))
const result = new Query<User>()
.where("name", Op.EQUAL, "Ana")
.or()
.group((g) => {
g.where("active", Op.EQUAL, true)
.and()
.group((inner) => {
inner.where("age", Op.LESS_THAN, 18).or().where("age", Op.GREATER_THAN, 65);
});
})
.build();
toGridify(result).filter; // "name=Ana|(active=true,(age<18|age>65))"Null checks
Gridify reads an empty value as "null or default". A comparison with null is written with no value:
const result = new Query<User>()
.where("deletedAt", Op.EQUAL, null)
.or()
.where("deletedAt", Op.NOT_EQUAL, null)
.build();
toGridify(result).filter; // "deletedAt=|deletedAt!="fromGridify reads an empty value as null.
Case-insensitive strings
A condition with caseInsensitive: true gets Gridify's /i suffix:
const result = new Query<User>()
.where("name", Op.CONTAINS, "jo", { caseInsensitive: true })
.build();
toGridify(result).filter; // "name=*jo/i"fromGridify reads a trailing /i back as caseInsensitive: true. On a value that is not a string, such as age=18/i, the /i is dropped.
Escaping
Gridify gives (, ), ,, |, \ and /i a meaning, so toGridify escapes them in values with a backslash, and fromGridify unescapes them:
const result = new Query<User>().where("name", Op.EQUAL, "Smith, (J|K)").build();
toGridify(result).filter; // "name=Smith\, \(J\|K\)"Value types
Gridify strings carry no types, so fromGridify guesses each value's type from its text:
| Text | Value | Example |
| ------------------------------------------------------- | --------- | ------------------------------------ |
| Empty | null | deletedAt= |
| true or false | boolean | active=true |
| A decimal number | number | age>-1.5 |
| An ISO 8601 date-time with a timezone (Z or ±hh:mm) | Date | createdAt>2024-01-02T03:04:05.000Z |
| Anything else | string | name=Ana |
A date without a time or timezone, such as 2024-01-02, stays a string. toGridify writes dates in the full ISO form, so they read back as Date.
When a guess would be wrong, for example a zip code 01234 that should stay a string, pass the types in options.types. Properties in the map are parsed as that type, and the rest are still guessed:
import { ValueType } from "@qfilter/core";
fromGridify<User & { zip: string }>(
{ filter: "zip=01234,createdAt>2024-01-02" },
{ types: { zip: ValueType.STRING, createdAt: ValueType.DATE } },
);
// zip = "01234" (string), createdAt > new Date("2024-01-02")A value that does not fit its mapped type throws, for example age=abc with age: ValueType.NUMBER.
Ordering
toGridify(new Query<User>().orderBy("address.city", "desc").build());
// { filter: "", orderBy: "address.city desc" }
fromGridify<User>({ filter: "", orderBy: "name" }).order;
// { property: "name", direction: "asc" }The direction defaults to asc when it is left out.
Errors
A badly ordered filter is rebuilt with Query, so it throws core's ValidationError with the same codes:
| Filter | Code |
| -------------------- | ------------------------------- |
| ,age=1 | INVALID_START |
| age=1\| | INVALID_END |
| age=1,\|age=2 | CONSECUTIVE_LOGICAL_OPERATORS |
| age=1,() | EMPTY_GROUP |
| age=1(age=2) | MISSING_CONNECTOR |
| (age=1 or age=1) | UNBALANCED_GROUP |
import { ValidationError } from "@qfilter/core";
try {
fromGridify<User>({ filter: "(age=1" });
} catch (error) {
if (error instanceof ValidationError) {
error.code; // "UNBALANCED_GROUP"
}
}Syntax that qfilter cannot represent throws a plain Error:
| Input | Why |
| ----------------------------------------- | ------------------------------------------ |
| age, age #in 1 | Missing or unknown operator |
| age=*5 | The operator does not fit the value's type |
| name=x\ (a trailing backslash) | Dangling escape |
| orderBy: "name asc, age desc" | qfilter orders by one property |
| orderBy: "deletedAt?" or "deletedAt!" | Nullable order markers are not supported |
| orderBy: "name up" | The direction must be asc or desc |
Round trip
For any QueryResult, fromGridify(toGridify(result)) gives back the same result, including escaped values, /i, null, dates and nested groups. The other way is not guaranteed: Gridify text is normalized (spaces removed), and values without a mapped type are guessed.
Development
vp install # install dependencies and the git hooks
vp test # run the tests
vp check # format, lint and type check
vp pack # build to dist/A pre-push hook runs vp test, so a push with failing tests is stopped. Skip it once with git push --no-verify.
Releasing
Releases use standard-version and Conventional Commits:
vp run release # bump the version, update CHANGELOG.md, commit and tag
git push --follow-tags
pnpm publish # builds through prepublishOnly, then publishesLicense
MIT. Report issues at github.com/jhael07/qf-gridify/issues.
