Filter DSL
The FilterDSL is a fluent TypeScript API that produces JSON-like graphs. To enable chaining, it creates instances with a prototype that allows further operations or conditions.
It is a unified condition language: components express and compose
conditions, and those conditions are then evaluated in the right location.
The same DSL exists on both sides – FilterDSL in TypeScript,
io.github.qlivedev.qlive.runtime.scalar.FilterDSL in Java – and a condition
travels between them as JSON. Where that sits in the framework as a whole
is Unified Domain.
Here we focus on using it for database operations, but the same graphs filter Java objects or JavaScript objects just as well.
Two ways in
Section titled “Two ways in”It is exported as a namespace, because it claims short, common names that would collide in an application’s import list:
import {FilterDSL} from "@qlivedev/qlive-ts";
const {field, value, values, and, or, not, component} = FilterDSL;A module that does little besides build conditions would rather not repeat the namespace, and can take the names straight from a second entry point:
import {field, value, values, and, or, not} from "@qlivedev/qlive-ts/filter";Both routes reach the same module. Conditions built through one are the same objects as conditions built through the other, so this is a choice to make per module and not once for an application.
Fluent and functional are one graph
Section titled “Fluent and functional are one graph”Fluent, reading left to right off a field:
const filter = field("name") .eq(value("Foo #1")) .and( field("num").greaterThan(value(10)) )or functional, which is the same graph:
const filter = and( field("name").eq(value("Foo #1")), field("num").greaterThan(value(10)))Nothing downstream can tell which of the two wrote a condition.
Semantic differences per technology
Section titled “Semantic differences per technology”Field paths are dotted and follow relations: field("owner.login") filters
on the login of the related user. The form is always the same. What the
path means is not.
For a database query condition, a path may cross a to-many relation –
bazes.name – and becomes a correlated EXISTS rather than a join.
For a Java object condition, the same path has memory graph semantics and
needs an index. bazes.0.name refers to one concrete baz, the first one
in the list. owner.login happens to express the same thing in both
worlds.
Values the database computes
Section titled “Values the database computes”value(v) infers the scalar type from the JavaScript value; pass the type
explicitly where the inference cannot be right. Two values are computed by
the database rather than by the client:
field("created").lessThan(FilterDSL.now())field("date").eq(FilterDSL.today())Every row of one query then sees the same “now”, on the same clock the rest of the schema’s defaults use.
Composition drops what contributed nothing
Section titled “Composition drops what contributed nothing”and() and or() drop falsy operands, and an empty one collapses to
null. That is the point of them rather than a convenience: it lets you
compose from helpers that may contribute nothing and let the final shape
fall out of whatever survived.
or( nameFilter, // may be null showAdmins && adminFilter, // boolean guard ownerFilter,)The type is LogicalOperand = FilterExpression | null | undefined | false.
Note that flag && cond only lands in it when flag is a boolean –
for a truthy-narrowable value write !!flag && cond or
flag ? cond : null, because "" | undefined | Condition is not an
operand.
A null operand is dropped on the server too: a filter component that
currently filters nothing constrains nothing at all.
Components mark where a term came from
Section titled “Components mark where a term came from”A component wraps a condition with an id:
component("nameFilter", field("name").contains(value(search)))Logically it evaluates as the condition it wraps – the database has no use
for the marker. findComponentNode(condition, id) finds one again, which
is how a form reads back the term it contributed.
The view below has two owners of one condition: its search form writes the
component search, the grid’s filter row the component grid, and the
document’s condition is both joined with and(). Each narrows what the other
shows, and clearing the search leaves the grid’s part alone.
import {DataGrid, FilterInput, flagSetFilter, i18n, patternFilter, useFilters, useInjection} from "@qlivedev/qlive-ts";import { field, value } from "@qlivedev/qlive-ts/filter";import { Q_FooList, Q_FooListResult } from "./Q_FooList";import FilterDSLPrinter from "../../component/FilterDSLPrinter";import React from "react";
/** * The search form's filters. Declared outside the view: useFilters() goes by their order, and a filter is a value * like any other. */const SEARCH = [ {field: "description", filter: patternFilter()}, { field: "flag", filter: flagSetFilter([ {name: "flagged", label: "Flagged", term: target => target.isTrue()}, {name: "large", label: "Num over 1000", term: () => field("num").gt(value(1000))} ]) }];
/** * A search form above the grid, filtering the same rows. * * The document has one condition and two owners of it: the form writes the component "search", the grid's filter * row the component "grid", and the condition is both joined with and(). Each narrows what the other shows, and * neither touches the other's part -- clear the search and the grid's filters stay. */export default function Search(){ const foos: Q_FooListResult = useInjection(Q_FooList, {config: {pageSize: 5}});
const search = useFilters(foos, "search", SEARCH); const [description, flags] = search.columns;
return ( <div className="grid-example"> <h1>A search form</h1> <form className="grid-example-search" onSubmit={ ev => ev.preventDefault() }> <label> Description matches <FilterInput column={ description }/> </label> <FilterInput column={ flags }/> <button type="button" className="btn" disabled={ !search.active } onClick={ () => search.reset() }> Clear search </button> </form> {/* the description shows, so a search by it finds what is on screen */} <DataGrid doc={ foos } columns={ ["name", "description", "flag", "num"] }/>
<dl> <dt> { i18n("Current Filter") }
</dt> <dd> <FilterDSLPrinter value={ foos.config.condition }/> </dd>
</dl>
</div> );}This demo shows filter composition. Both parts of the view: the search from and the datagrid can contribute to the final final, but each of them sticks to their own part of the whole marked by the component.query
The component approach is needed because each component needs to know which parts of the incoming QueryConfig belong to it. Wrapping the expression in a component always allows us to associate filter terms with components.
You can also just control the QueryConfig from the outside without using components. Sorting.tsx controls the sortFields with a select field from the outside. Note how the datagrid renders multi column or expression-based sort orders. Although it can’t trigger the state itself, it can still show the state.
Why the operators are not listed here
Section titled “Why the operators are not listed here”Every operator is a method on a field, value or condition node, and each is
also reachable as condition(name, operands) / operation(name, operands)
for building a graph programmatically.
The names are not invented: they are methods of jOOQ’s Field, dispatched
by name and operand count. That is also the security boundary – conditions
arrive from browsers, and a name the dispatch does not know never reaches
reflection.
They are installed on the node prototypes at runtime, so nothing in the generated type declarations names one, and a copy maintained by hand beside the map they come from is a copy that drifts. The list lives in Filter DSL in the API reference, which is generated from that map and gives each operator the number of operands it takes.
Sort fields
Section titled “Sort fields”sortFields is an array of field expressions, each a bare name or a node:
foos.update({sortFields: ["name"]})foos.update({sortFields: ["!created"]}) // the same as the following linefoos.update({sortFields: [field("created").desc()]})foos.update({sortFields: [field("a").plus(field("b"))]})With no sort fields the primary key is the sort. That is below the config: the config that comes back still names none.
An expression sorts like a field, and filters like one. The view below sorts
by field("numA").add(field("numB")), a column no table has: its header
toggles the direction, and between filters a range of sums. The server
would run the expression as SQL; this page runs it over recorded rows.
import { dateRangeFilter, DataGrid, FilterInput, operatorFilter, useFilters, useInjection } from "@qlivedev/qlive-ts";import { field } from "@qlivedev/qlive-ts/filter";import { Q_QuuxList, Q_QuuxListResult } from "./Q_QuuxList";import filterAsTitle from "../../util/filterAsTitle";
/** * The sum the computed column shows, as the server sorts by it. */const SUM = field("numA").add(field("numB"));
/** * The search form's one filter: the days the Quux was created on. */const SEARCH = [ {field: "created", filter: dateRangeFilter("Timestamp")}];
/** * A column computed from two fields, sorted by the expression that computes it, under a date range form. * * The document starts sorted by the sum, largest first. A click on the sum's header sorts by it ascending, the next * one descending again -- an expression toggles like a field. The sum filters like a field too: its filter compares * the expression the column sorts by, so `between` gives a range of sums. The form narrows the rows to a range of * days, from or until alone for a range open at the other end. */export default function Sum(){ // written out rather than SUM.desc(): the build reads this call, and it runs FilterDSL builders but does not // follow a variable to its value const quuxes: Q_QuuxListResult = useInjection(Q_QuuxList, { config: {sortFields: [field("numA").add(field("numB")).desc()]} });
const search = useFilters(quuxes, "search", SEARCH); const [created] = search.columns;
return ( <div className="grid-example"> <h1>Sorted by a sum</h1> <form className="grid-example-search" onSubmit={ ev => ev.preventDefault() }> <label> Created from, until <span className="grid-example-range"> <FilterInput column={ created }/> </span> </label> <button type="button" className="btn" disabled={ !search.active } onClick={ () => search.reset() }> Clear search </button> </form> <DataGrid doc={ quuxes } headerTitle={ filterAsTitle(quuxes.config)} columns={ [ "name", {field: "numA", filter: operatorFilter("between", "Int")}, {field: "numB", filter: operatorFilter("between", "Int")}, // computed in the browser, sorted and filtered on the server by the same sum { heading: "numA + numB", render: row => row.numA + row.numB, sort: SUM, filter: operatorFilter("between", "Int") }, // the form filters it already {field: "created", nowrap: true, filter: false} ] } /> </div> );}Nodes are class instances
Section titled “Nodes are class instances”The DSL produces nodes that are instances of its own classes. That works almost everywhere, but not quite everywhere – MobX, for example, ignores class instances and will not make them observable, and making the whole DSL observable would be a large cost for an exotic case.
So where you need the graph in its pure JSON form, call
const plain = FilterDSL.toJSON(condition);which converts it to plain objects and arrays. It is the same shape the condition travels in.