Package Exports
- strapi-query-builder
Readme
Strapi Query Builder v4
Utility for type safe queries for Strapi Headless CMS
# via npm
npm install strapi-query-builder // Old version not-recomended and will be updated to v5 soon
npm install strapi-query-builder@strapi4 // Strapi v4 version
Import with
const { SQBuilder } = require("strapi-query-builder");
// or
import { SQBuilder } from "strapi-query-builder";
Why?
Strapi has flexible syntax for building queries, as the project grows the logic of the queries becomes complex, it becomes difficult to keep track of errors in syntax.
For get the most out of the library use it with Typescript.
Features
- Advanced typescript autocompletion. When passing the model type to the query builder, it will give precise hints on which keys to use.
- Builder has two validation modes. Strict and non-strict. Strict mode works if there is
id
property in the model type. - Builder creates a query type where it specifies the fields and populate keys exactly. This makes it easy to integrate with the internal typing of
Strapi
services themselves. - The ability to attach query. You can write separate parts of a queries and then prepare complex query on the fly.
- Query compilation. Queries can be serialized into a TS/JS template and saved in the desired location. This way we can get complex and correct queries as if written by hand.
Builder types
SQBuilder
: Main entity service. For Strapi v4 it's Entity Service.QQBuilder
: Strapi Query Engine. Contains functions that are available only for Query Engine.RQBuilder
: REST API. Contains functions and differences that are specific to the REST API.
Basic example
import { SQBuilder } from "strapi-query-builder";
const query = new SQBuilder()
.eq("title", "Hello World")
.gte("createdAt", "2021-11-17T14:28:25.843Z")
.build();
// Creates
const builtQuery = {
filters: {
$and: [
{
title: { $eq: "Hello World" },
},
{
createdAt: { $gt: "2021-11-17T14:28:25.843Z" },
},
],
},
}; // For filters, the builder creates a generalized type.
We can improve readability through IDE autocompletion if we provide the type for which the query is executed.
import { SQBuilder } from "strapi-query-builder";
type Relation = {
id: number;
option: string;
}
type Entity = {
id: number;
title: string;
createdAt: string;
category: Relation
};
const query = new SQBuilder<Entity>()
.eq("title", "Hello World")
.gte("createdAt", "2021-11-17T14:28:25.843Z")
.eq("prop", "error") // Will give TS error, because this property does not exist in Entity.
.eq("category", "error") // Will give TS error, because category is a Relation, and we can't filter just by category, but only by its properties.
.eq("category.option", "Hello World") // No error, besides IDE will also give autocompletion on Relation keys.
.build();
Types
This util can be used without type, with type, and in strict typing mode.
Strict type is defined by the presence of an id
attribute in the type.
For example, this one will display autocompletion for filtering, sorting, and populate, but will not strictly require right keys.
type Entity = {
title: string;
createdAt: string;
};
This type will strictly require type keys, and will create type error if keys not provided correctly.
type Entity = {
id: number;
title: string;
createdAt: string;
};
Filtering
Builder contains all filter operators like .eq()
, notEq()
, contains()
, etc. Besides, there are special operators for adding deep filtering for the current model filterDeep()
or filtering for Relation, filterRelation()
.
const query = new SQBuilder<Entity>()
.eq("title", "Hello World") // or .filter("title", "$eq", "Hello World")
.filterDeep(() => new SQBuilder<Entity>()
.or()
.contains("updatedAt", "17.03.2022")
.contains("updatedAt", "24.02.2022")) // Creates another $or: [...] filter in current filters.
.filterRelation("category", () => new SQBuilder<Relation>().eq("option", "Hello")) // Autocompletion for all Relation keys of Entity. Creates a filter to filter by Relation.
.build();
Attributes
All attributes from the Strapi docs are supported. Strapi filters
Logical filter
There are three logical filters in Strapi, $and
$or
$not
. SQBuilder supports these filters.
By default, as in Strapi root filter is $and
.and()
const query = new SQBuilder()
.and() // For this case "and" is default and can be omited.
.eq("title", "one")
.containsi("createdAt", "2021")
.build();
.or()
const query = new SQBuilder()
.or() // Set root logical as $or
.eq("title", "one")
.containsi("createdAt", "2021")
.build();
// Creates
const builtQuery = {
filters: {
$or: [
{
title: { $eq: "one" },
},
{
createdAt: { $containsi: "2021" },
},
],
},
};
.not()
Logical $not
can negate all root. For example.
const query = new SQBuilder()
.not() // Negates all root filter
.or() // Set root logical operator as or
.eq("title", "one")
.containsi("createdAt", "2021")
.build();
// Creates
const builtQuery = {
filters: {
$not: {
$or: [
{
title: { $eq: "one" },
},
{
createdAt: { $containsi: "2021" },
},
],
},
},
};
To negate an attribute, just call .notEq()
etc. or use .filterNot("key", "$operator", "value")
const query = new SQBuilder()
.notEq("title", "one")
.build();
// Creates
const builtQuery = {
filters: {
title: { $not: { $eq: "one" } },
},
};
Nested filters and filter join
Nested filters
To create a nested filter, use the .filterDeep
operator.
To create a relation filter, use the .filterRelation
operator.
const builtQuery = new SQBuilder<ProductType>()
// Nested filter at same model level
.filterDeep(() => new SQBuilder<ProductType>()
.or()
.lte("createdAt", "date1")
.lte("createdAt", "date2")
)
// Filters by relations - with can acept type for typing nested builder
.filterRelation("Category", () => new SQBuilder<CategoryType>()
.or()
.contains("name", "phones")
.gte("createdAt", "date2")
)
.build();
In this case, you do not need to call the build on nested builders, it will be done automatically.
Filters join
In some cases, it's useful to divide the filtering into parts. For example, you would like to have one query to filter the category and then reuse it for example in the product query.
Let's rewrite the example above
// If query not built it can be used as a variable.
const filterCategories = new SQBuilder<CategoryType>()
.contains("name", "phones")
.gte("createdAt", "date");
const builtQuery = new SQBuilder<ProductType>()
// ...Other filters, population etc.
// Now we can join filters from other builder to any of nested or root builder
.filterRelation("Category", () => filterCategories)
// or
.filterRelation("Category", () => new SQBuilder<CategoryType>().joinFilters(filterCategories))
.build();
Populate
The population can be simple or really complex. For the populate of everything as in Strapi you can use .populateAll()
const populateAll = new SQBuilder().populateAll().build();
Or use the key list to populate.
const populateWithList = new SQBuilder().populates(["Category", "Seo"] as const).build();
// Or
const populateSpecific = new SQBuilder().populate("Category").populate("Seo").build();
Complex population
Strapi allows filtering, sorting, selecting fields from the populating data, or do populate at even deeper levels.
There are .populateRelation()
and .populateDynamic()
operators for this purpose.
const populateCategoriesWithFilter = new SQBuilder()
.populateRelation("Category", () =>
new SQBuilder<CategoryType>().eq("name", "phones")
)
.build();
Populate fragments (Dynamic Zones)
Strapi has a powerful solution in the form of dynamic zones. SQBuilder also supports this by .populateDynamic()
operator. Strapi populate fragment
const populateDynamicZone = new SQBuilder()
.populateDynamic("DynamicZone", "zone.component", () =>
new SQBuilder<Type>().fields(["title", "other"])
)
.build();
Same keys will be overridden.
Join function:
builder.joinPopulate(otherBuilder)
Sort, Fields, Pagination, PublicationState, Locale, Data
Sort
You can sort by key or by array of keys. Strapi Ordering
const filterCategories = new SQBuilder()
.sortAsc("key1") // Sort by one key
.sortsAsc(["key2", "key3"] as const) // Sort by array of keys
.sortDesc("key8.subkey") // Set "key8.subkey" as desc
.sort("key4", "$asc") // Set sorting explicitly
.build();
Same keys will be merged.
Join function:
builder.joinSort(otherBuilder)
Fields
Select the fields to be obtained, in the case of typing only simple attributes will be displayed, as well, as the same keys are merged.
const filterCategories = new SQBuilder()
.field("key1") // Single attribute
.fields(["key2", "key3"] as const) // Array of attributes
.build();
Same keys will be merged.
Join function:
builder.joinFields(otherBuilder)
Pagination
Strapi
has a high-level pagination API which is available only to Entity Service
and REST-API
and offset pagination for all query types.
const filterCategories = new SQBuilder()
.page(1) // Page pagination
.pageSize(24) // Change page size
.start(1) // Offest pagination
.limit(10) // Offset limit
.build();
const filterCategories = new QQBuilder()
.start(1) // Offest pagination only for Query Engine
.limit(10) // Offset limit only for Query Engine
.build();
const filterCategories = new RQBuilder()
.page(1, true) // Page pagination, and withCount parameter
.pageSize(24) // Change page size
.start(1, true) // Offest pagination, and withCount parameter
.limit(10) // Offset limit
.build();
Join function:
builder.joinPagination(otherBuilder)
PublicationState
Strapi has a Publication state which can be specified, but will only work for the SQBuilder
and RQBuilder
.
const filterCategories = new SQBuilder()
.publicationState("live") // Live
.publicationState("preview") // Preview
.build();
Locale
Strapi has a Locale which can be specified, but will only work for the SQBuilder
and RQBuilder
.
const filterCategories = new SQBuilder()
.locale("uk-UA") // Any string code
.build();
Data
It is also possible to add any type of Data and the data itself through the operator .data()
const filterCategories = new SQBuilder().data<DataType>(data).build();
PublicationState, Locale, Data don't merge and have no merge functions.
Applications and performance
Custom Strapi services
That's basically what it was designed to do. Here are a few points:
- Create queries on the fly in the services.
- Combine them on the fly from multiple queries.
- Create already compiled queries constants while running the application.
- Create a separate factory method for a specific API or a set of generalized queries. It's up to you.
If the queries are simple enough, you can do them with the standard object literals. But, Here is an example of a real query to populate certain fields from a dynamic zone
const dynamicLayoutPopulate = new SQBuilder()
.populateDynamic("Layout", "layout.alert", () => new SQBuilder<IAlert>()
.fields(["type", "message"] as const)
)
.populateDynamic("Layout", "layout.article", () => new SQBuilder<IArticle>().field("Article"))
.populateDynamic("Layout", "layout.slider", () => new SQBuilder<ISlider>()
.fields([
"SliderTimeoutSeconds",
"EnableDots",
"Arrows",
"AutoScroll",
"SideImages",
] as const)
.populateRelation("Slides", () => new SQBuilder<ILinkImage>()
.field("Link")
.populateRelation("Image", () => GenericBuilder.imgBuilder())
)
)
.populateDynamic("Layout", "layout.faq", () => new SQBuilder<IFaq>()
.fields(["Question", "Answer"] as const)
)
.populateDynamic("Layout", "layout.cardlist", () => new SQBuilder<IServerCard>()
.populateRelation("Cards", () => new SQBuilder<IServerCard>()
.fields(["Title", "Description"] as const)
.populateRelation("Image", () => GenericBuilder.imgBuilder())
)
);
Performance
Since these builders are often used to get static data for the frontend, it has little or no effect on the Strapi backend.
The project has a simple speed check for 500 iterations, constructing and parsing queries. The average build and parsing time takes 0.032ms.
All functions are covered by tests on 96%
Typescript performance
To ensure that your IDEA
and Typescript
engine does not start to slow down for large queries using complex data, Strapi Query Builder
limits the depth of key scanning for .sort()
and .filter()
operators.
By standard this depth is two, which means that you can write filtering and sorting with hints like parent.child
.
To increase the depth you can override this parameter using declare global
.
declare global {
interface QueryBuilderConfig {
DefaultScanDepth: 2;
}
}
Query Compilation
If you still prefer object literal
syntax, as it is by far the fastest.
You can still use the convenient query builder syntax with Typescript
checks.
Since such queries can be generated into a string template
and saved as a *.ts/js
file.
There is a function compileStrapiQuery
for this purpose.
The function accepts any builder and returns serialized data.
import { compileStrapiQuery } from "strapi-query-builder";
const serialized = compileStrapiQuery(new SQBuilder(), { compileSource: "typescript" });
// Returns
type SerializeOutput = {
query: string; // Serialized as object literal with additional as const casting for compatibility with type checks of Strapi Service.
constants: string; // compileStrapiQuery can find the same fields and sorts and put them into separate variables. This also slightly increases the speed of queries creation.
}
This is a more difficult idea to implement, as you will have to figure out separately how to store SQBuilder queries and how to store object literals.
To give an example as an idea.
// src/api/blog/query/static-queries.ts
import { SQBuilder } from "./sq-builder";
export default {
getBlogPostSitemap: () => new SQBuilder(),
getBlogPostPreview: () => new SQBuilder()
// ...
}
// src/utils/compile-static.ts
import blogQueries from "./src/api/blog/query/static-queries.ts";
import { compileStrapiQuery } from "strapi-query-builder";
const compileQuery = () => {
const compiledQuery = getCompiledQuery("blogQuery", blogQueries);
// Save compiledQuery as file for example src/api/blog/query/static-queries.query.ts
// Then we can add package script to run compile and for example call prettier to format *.query.ts files
// After compile inside services we can now use generated static-queries.query.ts file.
}
// Parse object as new object string template with functions that returns query as literals
const getCompiledQuery = (queryName: string, query: QueryObject) => {
const entries = Object.entries(query)
.map(([key, queryFabric]) => `${key}:${getQueryString(queryFabric())}`)
.join(",");
return `const ${queryName} = {${entries}};export default ${queryName};`;
};
// Create function string template
const getQueryString = (builder: any) => {
const compiled = compileStrapiQuery(builder);
return `() => {
${compiled.constants}
return ${compiled.query};
}`;
};
Backend part of Frontend (Next, Remix etc.)
If there are complex queries on the server side of your frontend, this builder can also be used with qs
const serializedQuery = qs.stringify(
new RQBuilder().publicationState("preview").build(),
{
encodeValuesOnly: true,
}
);
Improvements
If you have suggestions or improvements, I would love to see them. This project is somewhat non-commercial and has been used and tested in a personal project.
Licensing - MIT
You can copy the sources, and do with them whatever you want.