Skip to content

Commit

Permalink
Add Meilisearch for improved search results and filters (#60)
Browse files Browse the repository at this point in the history
  • Loading branch information
diogotcorreia authored Jun 30, 2021
1 parent 87c59df commit e65c4af
Show file tree
Hide file tree
Showing 14 changed files with 294 additions and 100 deletions.
17 changes: 12 additions & 5 deletions backend/api/category/services/category.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
'use strict';

/**
* Read the documentation (https://strapi.io/documentation/3.0.0-beta.x/concepts/services.html#core-services)
* to customize this service
*/
const escapeRegex = (str) => str.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&');

module.exports = {};
module.exports = {
getCategoriesIds(categoryName) {
if (!categoryName) return [];

return strapi
.query('category')
.model.find({ path: { $regex: `,${escapeRegex(categoryName)},` } })
.select('_id')
.then((result) => result.map((v) => (v ? v.toObject()._id : null)));
},
};
8 changes: 7 additions & 1 deletion backend/api/product/config/schema.graphql.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
module.exports = {
definition: `
type ProductSearchResult {
nbHits: Int!
products: [Product]!
}
`,
query: `
productBySlug(slug: String!): Product
productsSearch(query: String, sort: String, limit: Int, start: Int, category: String, priceRange: [Int]): [Product]
productsSearch(query: String, limit: Int, start: Int, category: String): ProductSearchResult
newProducts: [Product]
`,
resolver: {
Expand Down
39 changes: 19 additions & 20 deletions backend/api/product/controllers/Product.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,33 +45,32 @@ const addStockStatus = (entity) => {
return { ...entity, stockStatus: stockStatus };
};

const escapeRegex = (str) => str.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&');

module.exports = {
async searchEnhanced(ctx) {
try {
const { _category, _query, _sort, _limit, _start, _priceRange } = Joi.attempt(
ctx.query,
searchEnhancedSchema
);
const { _category, _query, _limit, _start } = Joi.attempt(ctx.query, searchEnhancedSchema);

const query = {};
const categoriesIds = await strapi.services.category.getCategoriesIds(_category);
const filterQuery = categoriesIds.map((id) => `category = "${id}"`).join(' OR ');

if (_category) query.category = escapeRegex(_category);
if (_query) query.query = escapeRegex(_query.substring(0, 30));
if (_sort) query.sort = _sort;
if (_limit) query.limit = _limit;
if (_start) query.start = _start;
if (_priceRange) {
query.minPrice = _priceRange[0];
query.maxPrice = _priceRange[1];
}
const searchResult = await strapi.services.productsearch.searchProduct(_query, {
limit: _limit,
offset: _start,
filters: filterQuery || undefined,
});

const entities = await strapi.services.product.searchEnhanced(query);
const { hits, nbHits } = searchResult;

return entities.map((entity) =>
sanitizeEntity(addStockStatus(entity), { model: strapi.models.product })
);
const products = (
await Promise.all(
hits.map(async (hit) => {
const product = await strapi.services.product.findOne({ id: hit._id });
return sanitizeEntity(addStockStatus(product), { model: strapi.models.product });
})
)
).filter((product) => product !== null);

return { nbHits, products };
} catch (e) {
if (Joi.isError(e)) {
ctx.throw(400, 'invalid input');
Expand Down
52 changes: 17 additions & 35 deletions backend/api/product/models/Product.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,39 +5,21 @@
*/

module.exports = {
// Before saving a value.
// Fired before an `insert` or `update` query.
// beforeSave: async (model) => {},
// After saving a value.
// Fired after an `insert` or `update` query.
// afterSave: async (model, result) => {},
// Before fetching all values.
// Fired before a `fetchAll` operation.
// beforeFetchAll: async (model) => {},
// After fetching all values.
// Fired after a `fetchAll` operation.
// afterFetchAll: async (model, results) => {},
// Fired before a `fetch` operation.
// beforeFetch: async (model) => {},
// After fetching a value.
// Fired after a `fetch` operation.
// afterFetch: async (model, result) => {},
// Before creating a value.
// Fired before an `insert` query.
// beforeCreate: async (model) => {},
// After creating a value.
// Fired after an `insert` query.
// afterCreate: async (model, result) => {},
// Before updating a value.
// Fired before an `update` query.
// beforeUpdate: async (model) => {},
// After updating a value.
// Fired after an `update` query.
// afterUpdate: async (model, result) => {},
// Before destroying a value.
// Fired before a `delete` query.
// beforeDestroy: async (model) => {},
// After destroying a value.
// Fired after a `delete` query.
// afterDestroy: async (model, result) => {}
lifecycles: {
afterCreate: async (result) => {
if (!result || !result._id) return;

await strapi.services.productsearch.updateProduct(result);
},
afterUpdate: async (result) => {
if (!result || !result._id) return;

await strapi.services.productsearch.updateProduct(result);
},
afterDelete: async (result) => {
if (!result || !result._id) return;

await strapi.services.productsearch.deleteProdut(result);
},
},
};
9 changes: 9 additions & 0 deletions backend/api/product/services/Product.js
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,15 @@ module.exports = {
return { ref, qnt, price: cost };
}

strapi.services.productsearch
.partialUpdateProduct({
_id,
reference: ref,
quantity: newStock,
price: newPrice,
})
.catch(console.error);

return { ref, qnt: newStock, price: newPrice };
} catch {
// Most likely product not found
Expand Down
65 changes: 65 additions & 0 deletions backend/api/product/services/productSearch.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
const { MeiliSearch } = require('meilisearch');
const _ = require('lodash');

const meili = new MeiliSearch({
host: strapi.config.get('custom.meiliHost', 'http://127.0.0.1:7700'),
apiKey: strapi.config.get('custom.meiliApiKey', ''),
});

const PUBLIC_FIELDS = [
'_id',
'slug',
'stockStatus',
'language',
'publishedDate',
'bookPages',
'bookPublisher',
'bookEdition',
'bookAuthor',
'reference',
'price',
'description',
'name',
'createdAt',
'updatedAt',
'category',
];

const extractCategory = (product) => ({
...product,
category: product.category && product.category._id,
});

const updateProduct = async (product) => {
if (product.show) {
await meili.index('product').addDocuments([_.pick(extractCategory(product), PUBLIC_FIELDS)]);
} else {
await deleteProduct(product);
}
};

const partialUpdateProduct = async (product) => {
if (product.show === false) {
await deleteProduct(product);
} else {
await meili.index('product').updateDocuments([_.pick(extractCategory(product), PUBLIC_FIELDS)]);
}
};

const deleteProduct = async (product) => {
try {
await meili.index('product').deleteDocument(product._id);
} catch (e) {
console.error(e);
console.error('Failed to delete product from meilisearch');
}
};

const searchProduct = (query, properties) => meili.index('product').search(query, properties);

module.exports = {
updateProduct,
partialUpdateProduct,
deleteProduct,
searchProduct,
};
2 changes: 2 additions & 0 deletions backend/config/custom.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,6 @@ module.exports = ({ env }) => ({
euPagoSandbox: env('EUPAGO_TOKEN', 'demo-').startsWith('demo-'),
frontendUrl: env('FRONTEND_URL', 'http://localhost:3000'),
previewSecret: env('PREVIEW_SECRET', ''),
meiliHost: env('MEILI_HOST', 'http://127.0.0.1:7700'),
meiliApiKey: env('MEILI_API_KEY', ''),
});
101 changes: 101 additions & 0 deletions backend/migrations/meilisearch.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
const { MeiliSearch } = require('meilisearch');
const path = require('path');
const fs = require('fs');
const _ = require('lodash');

const PUBLIC_FIELDS = [
'_id',
'slug',
'stockStatus',
'language',
'publishedDate',
'bookPages',
'bookPublisher',
'bookEdition',
'bookAuthor',
'reference',
'price',
'description',
'name',
'createdAt',
'updatedAt',
'category',
];

const createStrapiApp = async (projectPath) => {
if (!projectPath) {
throw new Error(`
-> Path to strapi project is missing.
-> Usage: node meilisearch.js [path]`);
}

let strapi;
let app;
try {
strapi = require(require.resolve('strapi', { paths: [projectPath] }));
const pkgJSON = require(path.resolve(projectPath, 'package.json'));
if (!pkgJSON || !pkgJSON.dependencies || !pkgJSON.dependencies.strapi) {
throw new Error();
}
} catch (e) {
throw new Error(`
-> Strapi lib couldn\'t be found. Are the node_modules installed?
-> Fix: yarn install or npm install`);
}

try {
app = await strapi({ dir: projectPath }).load();
} catch (e) {
throw new Error(`
-> The migration couldn't be proceed because Strapi app couldn't start.
-> ${e.message}`);
}

return app;
};

const extractCategory = (product) => ({
...product,
category: product.category && product.category._id,
});

const run = async () => {
const projectPath = process.argv[2];
const app = await createStrapiApp(projectPath);

const meili = new MeiliSearch({
host: app.config.get('custom.meiliHost', 'http://127.0.0.1:7700'),
apiKey: app.config.get('custom.meiliApiKey', ''),
});

const index = meili.index('product');

try {
await index.deleteAllDocuments();
} catch (e) {
// Ignore
}

const count = await app.services.product.count();
let products;
let i = 0;
while (i < count) {
products = await app.services.product.find({ _start: i });
i += products.length;
await index.addDocuments(
products
.filter((prod) => prod.show)
.map((prod) => _.pick(extractCategory(prod), PUBLIC_FIELDS))
);
}
};

run()
.catch((e) => {
console.error(e);
process.exit(1);
})
.then(() => {
console.log('Migration successfully finished! 🎉');
process.exit(0);
});
1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"crypto": "^1.0.1",
"email-templates": "^8.0.3",
"joi": "^17.4.0",
"meilisearch": "^0.18.1",
"sharp": "^0.27.2",
"strapi": "3.5.3",
"strapi-admin": "3.5.3",
Expand Down
14 changes: 14 additions & 0 deletions backend/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -3722,6 +3722,13 @@ cross-fetch@^3.0.4:
dependencies:
node-fetch "2.6.1"

cross-fetch@^3.0.5:
version "3.1.1"
resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.1.tgz#a7ed5a9201d46223d805c5e9ecdc23ea600219eb"
integrity sha512-eIF+IHQpRzoGd/0zPrwQmHwDC90mdvjk+hcbYhKoaRrEk4GEIDqdjs/MljmdPPoHTQudbmWS+f0hZsEpFaEvWw==
dependencies:
node-fetch "2.6.1"

cross-spawn@^6.0.0, cross-spawn@^6.0.5:
version "6.0.5"
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4"
Expand Down Expand Up @@ -7435,6 +7442,13 @@ [email protected]:
resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748"
integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=

meilisearch@^0.18.1:
version "0.18.1"
resolved "https://registry.yarnpkg.com/meilisearch/-/meilisearch-0.18.1.tgz#6b12da60628ec5d6438ba677a8295971e1035938"
integrity sha512-eGdcx5/0ktj5I2vC/bNbHg3ORuq9nh++fA39t03nG31J1SoFJmnx3Z0r83Q/yrfA+hAP/qhCI/6B8dHMEcCQJg==
dependencies:
cross-fetch "^3.0.5"

memoize-one@^5.0.0:
version "5.1.1"
resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.1.1.tgz#047b6e3199b508eaec03504de71229b8eb1d75c0"
Expand Down
Loading

0 comments on commit e65c4af

Please sign in to comment.