Package Exports
- strapi-query-builder
Readme
Strapi query builder
Utility for creating type safe queries for Strapi Headless CMS.
# via npm
npm install strapi-query-builderUse with Node.js:
const SQLBuilder = require("strapi-query-builder");or
import SQLBuilder from "strapi-query-builder";Requirement: Strapi v4 or above
Why?
Strapi has a very flexible syntax for building queries, but as the project grows or as the logic of the query becomes complex, it becomes difficult to keep track queries.
This library was just meant to be used for typical field tracking, but in the process of work it became necessary to use something more convenient.
For get the most out of the library with Typescript, but that does not prevent it from being used with pure JS as well.
Basic example
import SQLBuilder from "strapi-query-builder";
const query = new SQBuilder()
.filters("title")
.eq("Hello World")
.filters("createdAt")
.gte("2021-11-17T14:28:25.843Z")
.build();This is equivalent to writing
const query = {
filters: {
$and: [
{
title: { $eq: "Hello World" },
},
{
createdAt: { $gt: "2021-11-17T14:28:25.843Z" },
},
],
},
};We can improve readability through callbacks and add IDE autocompletion if we provide the type for which the query is executed
import SQLBuilder from "strapi-query-builder";
type Entity = {
title: string;
createdAt: string;
};
const query = new SQBuilder<Entity>()
.filters("title", (b) => b.eq("Hello World"))
.filters("createdAt", (b) => b.gte("2021-11-17T14:28:25.843Z"))
.build();This is a small example of filtering, but you can already build query for 3 Strapi types. It's similar but has different syntax's for Query.
const serviceBuilt = query.buildStrapiService(); // Build strapi default service factory query
const entityServiceBuilt = query.buildEntityService(); // Build strapi entity query
const queryEngineBuilt = query.buildQueryEngine(); // Build strapi query engine
// or
query.build("strapiService"); // Default
query.build("entityService");
query.build("queryEngine");Typing
This util can be used without type, with type, and in strict typing mode.
Strict type is defined by the presence of an id in the type.
For example, this one will display autocompletion for filtering, sorting, and population, but will not strictly require key compliance
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;
};A distinction is made between simple types for attribute values, objects and arrays of objects for populations. In order to give handy autocompletion or strict typing.
There are cases with types with cyclic dependency, this problem should not arise here
Filtering
To start the filters process, call the .filters(). The first argument can be an attribute, a callback, or just an empty filter.
const query = new SQBuilder()
.filters("title", (b) => b.eq("Hello World")) // Callback builder "b" is the same builder
.filters("updatedAt") // Chain start
.contains("24.02.2022") // Chain ends
.filters(
(b) =>
// Just empty filters to start another filter with deep nested query
b.with((nestedBuilder) => nestedBuilder.filters("nested").eq("other")) // With creates new builder for nesting, look exaple.
)
.build();Attributes
All attributes from the Strapi docs are supported. Strapi filters
const query = new SQBuilder()
.filters("title")
.eq("one")
.containsi("two")
.in(["three"])
.null(false)
.between(["five", "six"]) // For this chain last ".between()" filter will be applied
.build();Logical filter
There are 3 logical filters in Strapi, $and $or $not. SQBuilder implements these filters. There can be one logical filter per query, not counting nested queries.
By standard, as in Strapi root filter is $and
.and()
const query = new SQBuilder()
.and() // For this case "and" is default and can be omited. ".and()" filter is possible to install at any point of the query
.filters("title")
.eq("one")
.filters("createdAt")
.containsi("2021")
.build();.or()
const query = new SQBuilder()
.or() // Set root logical as or
.filters("title")
.eq("one")
.filters("createdAt")
.containsi("2021")
.build();This is equivalent to writing
const query = {
filters: {
$or: [
{
title: { $eq: "one" },
},
{
createdAt: { $containsi: "2021" },
},
],
},
};.not()
Logical $not can negate all root or any of attribute filter. For example, to negate root filter just add not on top level.
const query = new SQBuilder()
.not() // Negates all root filter
.or() // Set root logical as or
.filters("title")
.eq("one")
.filters("createdAt")
.containsi("2021")
.build();This is equivalent to writing
const query = {
filters: {
$not: {
$or: [
{
title: { $eq: "one" },
},
{
createdAt: { $containsi: "2021" },
},
],
},
},
};To negate an attribute, just set a .not() before the filter of that attribute.
const query = new SQBuilder()
.filters("title", (b) => b.not().eq("one"))
.build();This is equivalent to writing
const query = {
filters: {
title: { $not: { $eq: "one" } },
},
};Nested filters and filter joining
Nested filters
To create a nested filter, use the .with(callback) operator.
A nested filter can be either in the root filter structure or nested for an attribute.
const builtQuery = new SQBuilder<ProductType>()
// Just nested filter an same root level
.filters((b) =>
b.with((nestedBuilder) =>
nestedBuilder
.or()
.filters("createdAt", (b) => b.lte("date1"))
.filters("createdAt", (b) => b.gte("date2"))
)
)
// Filters by relations - with can acept type for typing nested builder
.filters("Category")
.with<CategoryType>((categoryBuilder) =>
categoryBuilder
.or()
.filters("name", (b) => b.contains("phones"))
.filters("createdAt", (b) => b.gte("date2"))
)
.build();In this case you do not need to call the build on nested builders.
Filters join
In some cases, it is 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 builder it can be used as a variable. So we create some Category query
const filterCategories = new SQBuilder<CategoryType>()
.filters("name", (b) => b.contains("phones"))
.filters("createdAt", (b) => b.gte("date2"));
const builtQuery = new SQBuilder<ProductType>()
// ...Other filters, population etc.
// Now we can join filters from other builder to any of nested or root builder
.filters("Category")
.with<CategoryType>((categoryBuilder) =>
categoryBuilder.joinFilters(filterCategories)
)
.build();And if types are specified, then if you try to attach queries for the wrong type - there will be type error.
JoinFilters has second boolean param
mergeRootLogicalto indicate whether the root logic filter needs to be overwritten by joined query Join function:builder.joinFilters(otherBuilder)
Populating
The population can be simple or very complex. For the population of everything as in Strapi you can use *
const filterCategories = new SQBuilder().populate("*").build();Or use the key list for the population
const filterCategories = new SQBuilder().populate(["Category", "Seo"]).build();If a type with attached data is provided, only the keys of these attached data will be displayed for the population
Join function:
builder.joinPopulation(otherBuilder)
A complex population
Strapi allows filtering sort, select fields from the resulting data, or do population at even deeper levels. To do this, it is enough to specify one key and with the second parameter get a new builder callback where we can perform filtering, sorting etc.
const filterCategories = new SQBuilder()
.populate<CategoryType>("Category", (categoryBuilder) =>
categoryBuilder.filters("name").eq("phones")
)
.build();That it, it's possible to attach filters, sorting or even populations to both sub-builders and the main builder.
Populate fragments
Strapi has a powerful solution in the form of dynamic zones. SQBuilder also supports this by .on operator. Strapi populate fragment
const filterCategories = new SQBuilder()
// Dynamic zone can contains morph types
.populate("DynamicZone", (zoneBuilder) =>
// With "on" filter we can define key of component, type
// and get component builder to create filtering, field selection or etc.
zoneBuilder.on<Type>("zone.component", (zoneComponent) => {
zoneComponent.fields(["title", "other"]);
})
)
.build();Sort, Fields, Pagination, PublicationState, Locale, Data
Sorting
You can sort by key, by array of keys, by array of objects with direction, as well as add .asc() .des() operators to it all. Strapi Ordering
const filterCategories = new SQBuilder({ defaultSort: "asc" }) // Set gloval default sort
.sort("key1") // Sort by one key
.sort(["key2", "key3"]) // Sort by array of keys
.sort("key4")
.asc() // Set last key4 as asc
.sort({ key: "key5", type: "asc" }) // Sort by raw object
.sort([{ key: "key6", type: "asc" }, [{ key: "key7", type: "asc" }]]) // Sort by array of raw object
.sort("key8.subkey") // Sort by object path notation
.desc() // Set "key8.subkey" as desc
.desc(true) // ".asc()" and ".desc()" can get one parametr to set all sort to one direction
.build();The 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()
.fields("key1") // Just key
.fields(["key2", "key3"]) // Array of keys
.build();Join function:
builder.joinFields(otherBuilder)
Pagination
Strapi has a high-level pagination API which is available only to StrapiService and RestAPI and offset pagination for EntityService and Query Engine
If pagination is specified for a page, it will be built only for the strapiService build. Offset pagination is built for all types of queries.
const filterCategories = new SQBuilder()
.page(1) // Page pagination
.pageSize(24) // Change page size
.pageStart(1) // Offest pagination
.pageLimit(10) // Offset limit
.build();Join function:
builder.joinPagination(otherBuilder)
PublicationState
Strapi has a high-level Publication state which can be specified, but will only work for the strapiService.
const filterCategories = new SQBuilder()
.publicationState("live") // Live
.publicationState("preview") // Preview
.build();Locale
Strapi has a high-level Locale which can be specified, but will only work for the strapiService.
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 because no such case was found.
Readonly
Some queries are inherently constants and do not change while the application is running.
For this, there is a .readonly(boolen) operator that merely blocks all operators except itself.
Readonly doesn't freeze the object, so you can turn it off at any time, only if you haven't already built a query.
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 service itself
- Combine them on the fly from multiple readonly
- Create already built 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
Of course, if your queries are very simple, then writing them manually in the form of an object is the best option.
But, Here is an example of a real query to get certain fields from a dynamic zone
const dynamicLayoutPopulate = new SQBuilder().populate(
"Layout",
(layoutBuilder) => {
layoutBuilder
.on<IAlert>("layout.alert", (alertBuilder) => {
alertBuilder.fields(["type", "message"]);
})
.on<IArticle>("layout.article", (articleBuilder) => {
articleBuilder.fields(["Article"]);
})
.on<ISlider>("layout.slider", (sliderBuilder) => {
sliderBuilder
.fields([
"SliderTimeoutSeconds",
"EnableDots",
"Arrows",
"AutoScroll",
"SideImages",
])
.populate<ILinkImage>("Slides", (photoBuilder) =>
photoBuilder
.fields(["Link"])
.populate<IPhoto>("Image", (imgBuilder) =>
imgBuilder.joinFields(imageFields)
)
);
})
.on<IServerCardList>("layout.cardlist", (serverCardBuilder) => {
serverCardBuilder.populate<IServerCard>("Cards", (cardBuilder) =>
cardBuilder
.fields(["Title", "Description"])
.populate<IPhoto>("Image", (photoBuilder) =>
photoBuilder.joinFields(imageFields)
)
);
})
.on<IFaq>("layout.faq", (faqBuilder) =>
faqBuilder.fields(["Question", "Answer"])
)
.on<ISocialLinks>("layout.social-links", (socialLinksBuilder) => {
socialLinksBuilder.populate<IIconLink>("Links", (b) => {
b.fields(["Link", "Alt"]).populate<IPhoto>("Icon", (imgb) =>
imgb.joinFields(imageFields)
);
});
});
}
);And I won't give you an example of how it looked like an object because it's even harder to read.
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 in combination with qs
const query = qs.stringify(
new SQBuilder().publicationState("live").publicationState("preview").build(),
{
encodeValuesOnly: true,
}
);Performance
Since these builders are often used to get static data for the frontend, it has no effect on the Strapi backend.
In the case of using the builder in frequently requested end-points, I also did not find a serious reduction in speed.
The project has a simple speed check for 100 iterations, constructing and parsing queries. The average build and parsing time takes 0.18ms. A better benchmark will be added in the future.
So all the key functions are covered by tests on 96%
Improvements
If you have suggestions or improvements, nice =) I would love to connect you to the project. This project is somewhat non-commercial and has been used and tested by me personally in my projects for a very long time.
Licensing
Since this was conceived as a utility for typing keys in Strapi query =) you can copy the sources and do with them whatever you want under you. This repository will be systematically updated