Create a generic Builder and Query APIs

This commit is contained in:
Kamal Bennani 2019-02-02 13:25:09 +01:00 committed by Alexandre Bodin
parent e8c92fc3d7
commit bd1930a75c
7 changed files with 303 additions and 17 deletions

View File

@ -43,6 +43,9 @@ Find products having a price equal or greater than `3`.
Find multiple product with id 3, 6, 8
`GET /products?id_in=3&id_in=6&id_in=8`
Find posts written by a user belongs to the strapi company.
`GET /posts?author.company.name=strapi`
::: note
You can't use filter to have specific results inside relation, like "Find users and only their posts older than yesterday" as example. If you need it, you can modify or create your own service or use [GraphQL](./graphql.md#query-api).
:::
@ -85,10 +88,11 @@ Get the second page of results.
## Programmatic usage
Requests system can be implemented in custom code sections.
In the newest version of strapi, we introduced the Builder API which is an easy/new way to manipulate filters.
### Extracting requests filters
To extract the filters from a JavaScript object or a request, you need to call the [`strapi.utils.models.convertParams` helper](../api-reference/reference.md#strapiutils).
To extract the filters from a JavaScript object or a request, you need to call the [`Builder` API](../api-reference/reference.md#strapiutils).
::: note
The returned objects are formatted according to the ORM used by the model.
@ -96,39 +100,75 @@ The returned objects are formatted according to the ORM used by the model.
#### Example
**Path —** `./api/user/controllers/User.js`.
**Path —** `./api/product/services/Product.js`.
```JS
const { Builder } = require('strapi-utils');
module.exports = {
// Find products having a price equal or greater than `3`.
fetchExpensiveProducts: (params, populate) => {
const filter = new Builder(Product)
.gte('price', 3)
.convert(); // the Convert method will convert your filter according to the Model's ORM (Mongoose/Bookshelf).
return new Query(Product)
.find(filter)
.populate(populate)
.execute();
},
// Find products that belongs to strapi company ordered by creation date.
fetchLastestStrapiProducts: (params, populate) => {
const filter = new Builder(Product)
.eq('company.name', 'strapi')
.contains('company.country', 'fr')
.sort('createdAt:DESC') // createdAt:desc will also work here
.convert();
return new Query(Product)
.find(filter)
.populate(populate)
.execute();
}
}
```
**Path —** `./api/product/controllers/Product.js`.
```js
const { Builder } = require('strapi-utils');
// Define a list of params.
const params = {
'_limit': 20,
'_sort': 'email'
'_sort': 'email:desc'
};
// Convert params.
const formattedParams = strapi.utils.models.convertParams('user', params); // { limit: 20, sort: 'email' }
const formattedParams = new Builder(Product, params).build(); // { limit: 20, sort: { order: 'desc', key: 'email' } }
const convertedParams = new Builder(Product, params).convert(); // { limit: 20, sort: '-email' }
```
### Query usage
#### Example
**Path —** `./api/user/controllers/User.js`.
**Path —** `./api/product/controllers/Product.js`.
```js
module.exports = {
const { Builder, Query } = require('strapi-utils');
module.exports = {
find: async (ctx) => {
// Convert params.
const formattedParams = strapi.utils.models.convertParams('user', ctx.request.query);
const filter = new Builder(Product, ctx.request.query).convert();
// Get the list of users according to the request query.
const filteredUsers = await User
.find()
.where(formattedParams.where)
.sort(formattedParams.sort)
.skip(formattedParams.start)
.limit(formattedParams.limit);
const filteredUsers = await new Query(Product)
.find(filter)
.execute();
// Finally, send the results to the client.
ctx.body = filteredUsers;

View File

@ -56,6 +56,7 @@ module.exports = scope => {
'dependencies': Object.assign({}, {
'lodash': '^4.17.5',
'strapi': getDependencyVersion(cliPkg, 'strapi'),
'strapi-utils': getDependencyVersion(cliPkg, 'strapi'),
[scope.client.connector]: getDependencyVersion(cliPkg, 'strapi'),
}, additionalsDependencies, {
[scope.client.module]: scope.client.version

View File

@ -134,7 +134,7 @@ module.exports = {
fetchAll: params => {
return strapi
.query('file', 'upload')
.find(strapi.utils.models.convertParams('file', params));
.find(params);
},
count: async () => {

View File

@ -0,0 +1,197 @@
const _ = require('lodash');
const getFilterKey = (key) => {
const matched = key.match(/^_?(sort|limit|start)$/);
if (matched) {
return matched[1];
}
return null;
};
const getOperatorKey = (key) => {
const matched = key.match(/(.*)_(neq?|lte?|gte?|containss?|n?in|exists)$/);
if (matched) {
return matched.slice(1);
}
return null;
};
class Builder {
constructor(model, filter) {
// Initialize Model
this.model = model;
// Initialize the default filter options
this.filter = {
limit: 100,
start: 0,
where: {}
};
if (!_.isEmpty(filter)) {
this.parse(filter);
}
}
parse(filter) {
for (const key of _.keys(filter)) {
const value = filter[key];
// Check if the key is a filter key
const filterKey = getFilterKey(key);
if (filterKey) {
this[filterKey].apply(this, [value]);
} else {
const matched = getOperatorKey(key);
let field = key;
let operation = 'eq';
if (matched) {
[field, operation] = matched;
}
this[operation].apply(this, [field, value]);
}
}
}
sort(sort) {
const [key, order = 'ASC'] = _.isString(sort) ? sort.split(':') : sort;
this.filter.sort = {
order: order.toLowerCase(),
key,
};
}
limit(limit) {
this.filter.limit = _.toNumber(limit);
}
start(start) {
this.filter.start = _.toNumber(start);
}
add(w) {
for (const k of _.keys(w)) {
if (k in this.filter.where) {
// Found conflicting keys, create an `and` operator to join the existing
// conditions with the new one
const where = {};
where.and = [this.filter.where, w];
this.filter.where = where;
return this;
}
}
// Merge the where items
this.filter.where = {
...this.filter.where,
...w
};
return this;
}
eq(key, value) {
const w = {};
w[key] = value;
return this.add(w);
}
neq(key, value) {
const w = {};
w[key] = { ne: value };
return this.add(w);
}
ne(key, value) {
// This method needs to be deprecated in favor of neq
return this.neq(key, value);
}
exists(key, value) {
const w = {};
w[key] = { exists: value };
return this.add(w);
}
in(key, value) {
const w = {};
w[key] = { in: value };
return this.add(w);
}
nin(key, value) {
const w = {};
w[key] = { nin: value };
return this.add(w);
}
contains(key, value) {
const w = {};
w[key] = {
contains: value,
};
return this.add(w);
}
containss(key, value) {
const w = {};
w[key] = {
containss: value,
};
return this.add(w);
}
startsWith(key, value) {
const w = {};
w[key] = { startsWith: value };
return this.add(w);
}
endsWith(key, value) {
const w = {};
w[key] = { endsWith: value };
return this.add(w);
}
gt(key, value) {
const w = {};
w[key] = { gt: value };
return this.add(w);
}
gte(key, value) {
const w = {};
w[key] = { gte: value };
return this.add(w);
}
lt(key, value) {
const w = {};
w[key] = { lt: value };
return this.add(w);
}
/**
* Implements <= operation
* @param {string} key field id
* @param {number} value its value
*/
lte(key, value) {
const w = {};
w[key] = { lte: value };
return this.add(w);
}
convert() {
const hook = strapi.hook[this.model.orm];
const { Converter } = hook.load();
return new Converter(this.model, this.filter).convert();
}
build() {
return this.filter;
}
}
module.exports = {
Builder
};

View File

@ -16,5 +16,7 @@ module.exports = {
packageManager: require('./packageManager'),
policy: require('./policy'),
regex: require('./regex'),
templateConfiguration: require('./templateConfiguration')
templateConfiguration: require('./templateConfiguration'),
Query: require('./query').Query,
Builder: require('./builder').Builder
};

View File

@ -9,6 +9,7 @@ const path = require('path');
// Public node modules.
const _ = require('lodash');
const pluralize = require('pluralize');
// Following this discussion https://stackoverflow.com/questions/18082/validate-decimal-numbers-in-javascript-isnumeric this function is the best implem to determine if a value is a valid number candidate
const isNumeric = (value) => {
@ -309,6 +310,16 @@ module.exports = {
return _.get(strapi.models, collectionIdentity.toLowerCase() + '.orm');
},
/**
* Return table name for a collection many-to-many
*/
getCollectionName: (associationA, associationB) => {
return [associationA, associationB]
.sort((a, b) => a.collection < b.collection ? -1 : 1)
.map(table => _.snakeCase(`${pluralize.plural(table.collection)} ${pluralize.plural(table.via)}`))
.join('__');
},
/**
* Define associations key to models
*/
@ -338,7 +349,7 @@ module.exports = {
// Build associations object
if (association.hasOwnProperty('collection') && association.collection !== '*') {
definition.associations.push({
const ast = {
alias: key,
type: 'collection',
collection: association.collection,
@ -348,7 +359,13 @@ module.exports = {
dominant: details.dominant !== true,
plugin: association.plugin || undefined,
filter: details.filter,
});
};
if (infos.nature === 'manyToMany' && definition.orm === 'bookshelf') {
ast.tableCollectionName = this.getCollectionName(association, details);
}
definition.associations.push(ast);
} else if (association.hasOwnProperty('model') && association.model !== '*') {
definition.associations.push({
alias: key,

View File

@ -0,0 +1,29 @@
class Query {
constructor(model) {
this.model = model;
const hook = strapi.hook[this.model.orm];
const ORMQuery = hook.load().Query;
this.instance = new ORMQuery(this.model);
}
find(...args) {
return this.instance.find(...args);
}
count(filter) {
return this.instance.count(filter);
}
populate(populate) {
return this.instance.populate(populate);
}
execute() {
return this.instance.execute();
}
}
module.exports = {
Query
};