Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,30 @@ Resulting in `categories` being transformed like this in a `restaurant` entry.

By transforming the `categories` into an array of names, it is now compatible with the [`filtering` feature](https://docs.meilisearch.com/reference/features/filtering_and_faceted_search.html#configuring-filters) in MeiliSearch.

#### 🏗 Add MeiliSearch Settings

Each index in MeiliSearch can be customized with specific settings. It is possible to add your [MeiliSearch settings](https://docs.meilisearch.com/reference/features/settings.html#settings) configuration to the indexes you create using `settings` field in your model's config.

The settings are added when either: adding a collection to MeiliSearch or when updating a collection in MeiliSearch. The settings are not updated when documents are added through the [`listeners`](-apply-hooks).

**For example**
```js
module.exports = {
meilisearch: {
settings: {
filterableAttributes: ['genres'],
distinctAttribute: null,
searchableAttributes: ['title', 'description', 'genres'],
synonyms: {
wolverine: ['xmen', 'logan'],
logan: ['wolverine', 'xmen']
}
}
},
}
```

[See resources](./resources/meilisearch-settings) for more settings examples.

### 🕵️‍♀️ Start Searching <!-- omit in toc -->

Expand Down
4 changes: 3 additions & 1 deletion connectors/__tests__/custom-index-name.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@ const createCollectionConnector = require('../collection')
jest.mock('meilisearch')

const addDocumentsMock = jest.fn(() => 10)
const updateSettingsMock = jest.fn(() => 10)

const mockIndex = jest.fn(() => ({
addDocuments: addDocumentsMock,
updateSettings: updateSettingsMock,
}))

MeiliSearch.mockImplementation(() => {
Expand Down Expand Up @@ -50,7 +53,6 @@ const loggerMock = {
describe('Test custom index names', () => {
let storeConnector
beforeEach(async () => {
jest.resetAllMocks()
jest.clearAllMocks()
jest.restoreAllMocks()
storeConnector = createStoreConnector({
Expand Down
4 changes: 3 additions & 1 deletion connectors/__tests__/entry-transformer.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@ const createCollectionConnector = require('../collection')
jest.mock('meilisearch')

const addDocumentsMock = jest.fn(() => 10)
const updateSettingsMock = jest.fn(() => 10)

const mockIndex = jest.fn(() => ({
addDocuments: addDocumentsMock,
updateSettings: updateSettingsMock,
}))

MeiliSearch.mockImplementation(() => {
Expand Down Expand Up @@ -76,7 +79,6 @@ describe('Entry transformation', () => {
})

afterEach(() => {
jest.resetAllMocks()
jest.clearAllMocks()
jest.restoreAllMocks()
})
Expand Down
159 changes: 159 additions & 0 deletions connectors/__tests__/settings.tests.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
const { MeiliSearch } = require('meilisearch')
const createMeiliSearchConnector = require('../meilisearch')
const createStoreConnector = require('../store')
const createCollectionConnector = require('../collection')

jest.mock('meilisearch')

const addDocumentsMock = jest.fn(() => 10)
const updateSettingsMock = jest.fn(() => 10)

const mockIndex = jest.fn(() => ({
addDocuments: addDocumentsMock,
updateSettings: updateSettingsMock,
}))

MeiliSearch.mockImplementation(() => {
return {
getOrCreateIndex: () => {
return mockIndex
},
index: mockIndex,
}
})

const storeClientMock = {
set: jest.fn(() => 'test'),
get: jest.fn(() => 'test'),
}

const servicesMock = {
restaurant: {
count: jest.fn(() => {
return 11
}),
find: jest.fn(() => {
return [{ id: '1', collection: [{ name: 'one' }, { name: 'two' }] }]
}),
},
}

const transformEntryMock = jest.fn(function ({ entry }) {
const transformedEntry = {
...entry,
collection: entry.collection.map(cat => cat.name),
}
return transformedEntry
})

const loggerMock = {
warn: jest.fn(() => 'test'),
}

describe('Test MeiliSearch settings', () => {
let storeConnector
beforeEach(async () => {
jest.clearAllMocks()
jest.restoreAllMocks()
storeConnector = createStoreConnector({
storeClient: storeClientMock,
})
})

test('Test not settings field in configuration', async () => {
const modelMock = {
restaurant: {
meilisearch: {
indexName: 'my_restaurant',
transformEntry: transformEntryMock,
},
},
}
const collectionConnector = createCollectionConnector({
logger: loggerMock,
models: modelMock,
services: servicesMock,
})
const meilisearchConnector = await createMeiliSearchConnector({
collectionConnector,
storeConnector,
})
const getIndexNameSpy = jest.spyOn(collectionConnector, 'getIndexName')
const getSettingsSpy = jest.spyOn(collectionConnector, 'getSettings')

await meilisearchConnector.addCollectionInMeiliSearch('restaurant')

expect(servicesMock.restaurant.count).toHaveBeenCalledTimes(1)

expect(getIndexNameSpy).toHaveBeenCalledWith('restaurant')
expect(getIndexNameSpy).toHaveReturnedWith('my_restaurant')
expect(getSettingsSpy).toHaveBeenCalledWith('restaurant')
expect(getSettingsSpy).toHaveReturnedWith({})
})

test('Test a empty setting object in configuration', async () => {
const modelMock = {
restaurant: {
meilisearch: {
indexName: 'my_restaurant',
transformEntry: transformEntryMock,
settings: {},
},
},
}
const collectionConnector = createCollectionConnector({
logger: loggerMock,
models: modelMock,
services: servicesMock,
})
const meilisearchConnector = await createMeiliSearchConnector({
collectionConnector,
storeConnector,
})
const getIndexNameSpy = jest.spyOn(collectionConnector, 'getIndexName')
const getSettingsSpy = jest.spyOn(collectionConnector, 'getSettings')

await meilisearchConnector.addCollectionInMeiliSearch('restaurant')

expect(servicesMock.restaurant.count).toHaveBeenCalledTimes(1)

expect(getIndexNameSpy).toHaveBeenCalledWith('restaurant')
expect(getIndexNameSpy).toHaveReturnedWith('my_restaurant')
expect(getSettingsSpy).toHaveBeenCalledWith('restaurant')
expect(getSettingsSpy).toHaveReturnedWith({})
})

test('Test a setting object with one field in configuration', async () => {
const modelMock = {
restaurant: {
meilisearch: {
indexName: 'my_restaurant',
transformEntry: transformEntryMock,
settings: {
searchableAttributes: ['*'],
},
},
},
}
const collectionConnector = createCollectionConnector({
logger: loggerMock,
models: modelMock,
services: servicesMock,
})
const meilisearchConnector = await createMeiliSearchConnector({
collectionConnector,
storeConnector,
})
const getIndexNameSpy = jest.spyOn(collectionConnector, 'getIndexName')
const getSettingsSpy = jest.spyOn(collectionConnector, 'getSettings')

await meilisearchConnector.addCollectionInMeiliSearch('restaurant')

expect(servicesMock.restaurant.count).toHaveBeenCalledTimes(1)

expect(getIndexNameSpy).toHaveBeenCalledWith('restaurant')
expect(getIndexNameSpy).toHaveReturnedWith('my_restaurant')
expect(getSettingsSpy).toHaveBeenCalledWith('restaurant')
expect(getSettingsSpy).toHaveReturnedWith({ searchableAttributes: ['*'] })
})
})
21 changes: 21 additions & 0 deletions connectors/collection.js
Original file line number Diff line number Diff line change
Expand Up @@ -152,5 +152,26 @@ module.exports = ({ services, models, logger }) => {
}
return entries
},

/**
* Returns MeiliSearch index settings from model definition.
* @param collection - Name of the Collection.
* @typedef Settings
* @type {import('meilisearch').Settings}
* @return {Settings} - MeiliSearch index settings
*/
getSettings: function (collection) {
const model = models[collection].meilisearch || {}
const settings = model.settings || {}

if (typeof settings !== 'object') {
logger.warn(
`[MEILISEARCH]: "settings" provided in the model of the ${collection} must be an object.`
)
return {}
}

return settings
},
}
}
4 changes: 4 additions & 0 deletions connectors/meilisearch/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,10 @@ module.exports = async ({ storeConnector, collectionConnector }) => {
const client = MeiliSearch({ apiKey, host })
const indexUid = collectionConnector.getIndexName(collection)

// Get MeiliSearch Index settings from model
const settings = collectionConnector.getSettings(collection)
await client.index(indexUid).updateSettings(settings)

// Callback function for batching action
const addDocuments = async (entries, collection) => {
if (entries.length === 0) {
Expand Down
3 changes: 3 additions & 0 deletions cypress/integration/ui_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ describe('Strapi Login flow', () => {
})

it('Add credentials', () => {
cy.removeNotifications()
cy.get('input[name="MSHost"]').clear().type(host)
cy.get('input[name="MSApiKey"]').clear().type(apiKey)
cy.get('.credentials_button').click()
Expand Down Expand Up @@ -98,6 +99,7 @@ describe('Strapi Login flow', () => {
})
cy.contains('Reload needed', { timeout: 10000 })
cy.reloadServer()
cy.removeNotifications()
})

it('Check for successfull listened in develop mode', () => {
Expand Down Expand Up @@ -205,6 +207,7 @@ describe('Strapi Login flow', () => {
cy.contains('Reload needed', { timeout: 10000 })
}
cy.reloadServer()
cy.removeNotifications()
})

it('Check that collections are not in MeiliSearch anymore', () => {
Expand Down
5 changes: 4 additions & 1 deletion playground/api/restaurant/models/restaurant.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ module.exports = {
};
return transformed;
},
indexName: "my_restaurant"
indexName: "my_restaurant",
settings: {
"searchableAttributes": ["*"]
}
}
}
2 changes: 1 addition & 1 deletion resources/entries-transformers/dates-transformer.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ function dateToTimeStamp(date) {

module.exports = {
meilisearch: {
transformEntry(entry) {
transformEntry({ entry }) {
const transformedEntry = {
...entry,
// transform date format to timestamp
Expand Down
2 changes: 1 addition & 1 deletion resources/entries-transformers/filter-compatibility.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

module.exports = {
meilisearch: {
transformEntry(entry) {
transformEntry({ entry }) {
const transformedEntry = {
...entry,
categories: entry.categories.map(cat => cat.name), // map to only have categories name
Expand Down
2 changes: 1 addition & 1 deletion resources/entries-transformers/remove-fields.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

module.exports = {
meilisearch: {
transformEntry(entry) {
transformEntry({ entry }) {
const transformedEntry = entry
// remove created by and updated by fields
delete transformedEntry.created_by
Expand Down
17 changes: 17 additions & 0 deletions resources/meilisearch-settings/basic-example.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* Adds the settings specific to this collection in MeiliSearch.
*/

module.exports = {
meilisearch: {
settings: {
filterableAttributes: ['genres'],
distinctAttribute: null,
searchableAttributes: ['title', 'description', 'genres'],
synonyms: {
wolverine: ['xmen', 'logan'],
logan: ['wolverine', 'xmen'],
},
},
},
}
37 changes: 37 additions & 0 deletions resources/meilisearch-settings/make-relationship-filterable.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* Imagine if you have a collection restaurant with a category field.
* Category is a collection of its own. They have a many-to-many relationship.
*
* In MeiliSearch you can use filters to for example in our case, only find italian restaurants.
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* In MeiliSearch you can use filters to for example in our case, only find italian restaurants.
* In MeiliSearch you can use filters to, for example in our case, only find italian restaurants.

* To be able to do that, you need to provide a list of values and add in the settings the field in `filterableAttributes`.
* See guide: https://docs.meilisearch.com/reference/features/filtering_and_faceted_search.html#configuring-filters
*
* In Strapi, when fetching an entry the many-to-many relationships are inside an object:
*
* {
* id: 1,
* restaurant_name: "The squared pizza",
* category: [
* { id: 1, name: "italian" },
* * { id: 2, name: "French" }
* ]
* }
*
* Since MeiliSearch is expecting `category: ["Italian", "french"]` and
* also `category` to be in `filterableAttributes` we can use the model configuration file to provide all these informations.
*/

module.exports = {
meilisearch: {
settings: {
filterableAttributes: ['categories'], // add categories to filterable attributes.
},
transformEntry({ entry }) {
const transformedEntry = {
...entry,
categories: entry.categories.map(cat => cat.name), // map categories to only have categories name.
}
return transformedEntry
},
},
}