NEW
Building Flexible, Dynamic Filters with the Fauna Query Language
When building applications with the Fauna Query Language, you’ll often need to search through collections using multiple dynamic criteria. These criteria might include equality conditions, range queries, relationships, or custom logic. Managing these queries can quickly become complicated, especially when the list of search terms isn’t fixed and can grow or change on the fly.
In traditional database querying, you might end up with rigid or repetitive logic that doesn’t easily scale as your application’s complexity grows. Composition with Fauna’s FQL enables dynamic construction of queries—selecting indexes, filters, and conditions based on your current search terms—you can write code that’s both more flexible and easier to maintain.
This post explores a pattern for dynamically generating complex FQL. We’ll walk through some core examples, discuss performance considerations, and introduce a higher-level function that can piece together queries from a prioritized set of indexes and filters. By the end, you’ll have a template for managing complex, multi-criteria search without getting bogged down in repetitive, brittle code.
Problem Scenario
You have an application that needs to search through a collection with any number of search terms. These terms may involve equality checks, inequalities, verifying relationships, or other logic. While Fauna’s documentation offers guidance on working with multiple Sets, how do you scale this up to handle unknown, dynamic combinations of search criteria?
Some Examples with FQL
An intersection returns elements that exist in multiple Sets, similar to an INTERSECT clause in SQL. To perform intersections in FQL, start with the most selective expression and filter the resulting Sets using set.where().
For the best performance, use a covered index call as the first expression and only filter on covered values.
// Get the produce category.
let produce = Category.byName("produce").first()
// Use the most selective index, `byName()`, first.
// Then filter with covered values.
// In this example, `category`, `price`, and `name`
// should be covered by the `byName()` index.
Product.byName("limes")
.where(.category == produce)
.where(.price < 500) {
name,
category: .category!.name,
price
Building with a Driver
So, how do we build queries like these dynamically at runtime? The Fauna client drivers compose queries using FQL template strings. You can interpolate variables, including other FQL template strings, into the template strings to compose dynamic queries.
Below is a simple JavaScript example that demonstrates how to compose queries. In the JS driver, you can use the
fql
tagged template function to create and reuse snippets:let query = fql`Product.byName("limes")`
const filters = [
fql`.where(doc => doc.category == Category.byName("produce").first()!)`,
fql`.where(doc => doc.price < 500 )`
]
filters.forEach(filter => {
query = fql`${query}${filter}`
})
Here, we start with a base query and then append filters dynamically. This pattern of interpolation makes it easy to add or remove conditions based on runtime logic.
Advanced Example
Let’s step up the complexity. Below is a TypeScript/JavaScript example that prioritizes certain indexes and filters when building queries based on a set of search terms. It allows you to define multiple strategies, ensuring that your query uses the most efficient approach available for any given search criterion.
/**
* A Javascript object with a sorted list of indexes or filters.
*
* Javascript maintains key order for objects.
* Sort items in the map from most to least selective.
*/
type QueryMap = Record<string, (...args: any[]) => Query>
/** Object to represent a search argument.
*
* Contains the name of the index to use and the arguments
* to pass to it.
*
* Example:
* { name: "by_name", args: ["limes"] }
* { name: "range_price", args: [{ from: 100, to: 500 }] }
*/
type SearchTerm = {
name: string
args: any[]
}
/**
* Composes a query by prioritizing the most selective index and then
* applying filters.
*
* @param default_query - The initial query to which indexes and filters are applied.
* @param index_map - A map of index names to functions that generate query components.
* @param filter_map - A map of filter names to functions that generate query components.
* @param search_terms - An array of search terms that specify the type and arguments
* for composing the query.
* @returns The composed query after applying all relevant indices and filters.
*/
const build_search = (
default_query: Query,
index_map: QueryMap,
filter_map: QueryMap,
search_terms: SearchTerm[]
): Query => {
const _search_terms = [...search_terms]
// Initialize a default query. Used if no other indexes are applicable.
let query: Query = default_query
// Iterate through the index map, from most to least selective.
build_index_query: for (const index_name of Object.keys(
index_map
)) {
// Iterate through each search term to check if it matches the highest priority index.
for (const search_term of _search_terms) {
// If a match is found, update the query. Then remove the search term from the
// list and break out of the loop.
if (index_name === search_term.name) {
query = index_map[search_term.name](...search_term.args)
_search_terms.splice(_search_terms.indexOf(search_term), 1)
break build_index_query
}
}
}
// Iterate through the filter map, from most to least selective.
for (const filter_name of Object.keys(filter_map)) {
// Iterate through each search term to check if it matches the highest priority filter.
for (const search_term of _search_terms) {
// If a match is found, update the query. Then remove the search term from the list.
if (filter_name === search_term.name) {
const filter = filter_map[search_term.name](...search_term.args)
query = fql`${query}${filter}`
_search_terms.splice(_search_terms.indexOf(search_term), 1)
}
}
}
// If there are remaining search terms, you can't build the full query.
if (_search_terms.length > 0) {
throw new Error("Unable to build query")
}
return query
}
This function picks the most selective index first, then falls back to filters if needed. You define
index_map
and filter_map
objects that map search criteria to FQL snippets, and build_search
stitches them together based on the search terms provided.An example:
// Implementation of `index_map` from the template.
// Sort items in the map from most to least selective.
const product_index_priority_map: QueryMap = {
by_order: (id: string) =>
fql`Order.byId(${id})!.items.map(.product!)`,
by_name: (name: string) => fql`Product.byName(${name})`,
by_category: (category: string) =>
fql`Product.byCategory(Category.byName(${category}).first()!)`,
range_price: (range: { from?: number; to?: number }) =>
fql`Product.sortedByPriceLowToHigh(${range})`,
}
// Implementation of `filter_map` from the template.
// Sort items in the map from most to least selective.
const product_filter_map: QueryMap = {
by_name: (name: string) => fql`.where(.name == ${name})`,
by_category: (category: string) =>
fql`.where(.category == Category.byName(${category}).first()!)`,
range_price: ({ from, to }: { from?: number; to?: number }) => {
// Dynamically filter products by price range.
if (from && to) {
return fql`.where(.price >= ${from} && .price <= ${to})`
} else if (from) {
return fql`.where(.price >= ${from})`
} else if (to) {
return fql`.where(.price <= ${to})`
}
return fql``
},
}
const order_id = (await client.query(fql`Order.all().first()!`))
.data.id
const query = build_search(
fql`Product.all()`,
product_index_priority_map,
product_filter_map,
[
// { name: "name", args: ["limes"] },
// { name: "category", args: ["produce"] },
{ name: "price", args: [{ to: 1000 }] },
{ name: "order", args: [order_id] },
]
)
const res = await client.query(query)
In this example, the code tries to find a relevant index first, then applies filters as needed. You can extend this logic to handle more complex scenarios, such as OR conditions or choosing the best index when multiple might apply.
Conclusion
By mixing and matching indexes and filters dynamically, you can create flexible, performant queries that adapt to changing search requirements. The code patterns shown here offer starting points for building your own dynamic query generators, ensuring your application can handle everything from a single straightforward filter to a complex matrix of search terms—all while taking advantage of Fauna’s powerful indexing and querying capabilities.
We’d love to hear your feedback and suggestions on extending this approach. Have you tried OR conditions, alternative indexing strategies, or integrated this approach into your build pipeline? Share your experiences on the Fauna Discord and let us know what’s worked best for your projects!
If you're just getting started with Fauna, sign-up for free and schedule a demo if you'd like to talk through your use case.
If you enjoyed our blog, and want to work on systems and challenges related to globally distributed systems, and serverless databases, Fauna is hiring
Subscribe to Fauna's newsletter
Get latest blog posts, development tips & tricks, and latest learning material delivered right to your inbox.