Skip to content

Commit

Permalink
feature-[167940543]: Implement global search
Browse files Browse the repository at this point in the history
- Extend search feature to allow searching all models with one query

[Delivers #167940543]
  • Loading branch information
chialuka committed Aug 23, 2019
1 parent b0eecaa commit 23ba6d6
Show file tree
Hide file tree
Showing 10 changed files with 181 additions and 46 deletions.
69 changes: 44 additions & 25 deletions server/controllers/Search.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { Op } from 'sequelize';
import models from '../database/models';
import { paginationValues, serverResponse, serverError } from '../helpers';
import searchHelper from '../helpers/searchHelper';

const { User, Article } = models;
const { User, Article, Tag } = models;

/**
* @class
Expand All @@ -17,45 +16,65 @@ class Search {
* @returns {JSON} Json object
*/
static async findQuery(req, res) {
const { page } = req.query;
const { page, global } = req.query;
if (global) return Search.globalSearch(req, res);
const {
modelToSearch: { model }
} = searchHelper(req);
const searchResponse = await Search.searchSingleModel(req, res, model);
if (searchResponse.count > 0) {
return serverResponse(res, 200, {
currentPage: page,
data: searchResponse
});
}
return serverResponse(res, 404, {
error: 'your query did not match any results'
} = searchHelper(req.query);
const searchResponse = await Search.modelSearch(req.query, res, model);
return serverResponse(res, 200, {
currentPage: page,
data: searchResponse
});
}

/**
* @param {Object} req
* @param {Object} query
* @param {Object} res
* @param {String} model model to perform search query on
* @returns {Object} object with result of seach
*/
static async searchSingleModel(req, res, model) {
static async modelSearch(query, res, model) {
try {
const { searchFields } = searchHelper(req);
const availableModels = [{ model: User }, { model: Article }];
const searchResult = await model.findAndCountAll({
distinct: true,
where: {
[Op.or]: searchFields
},
...paginationValues(req.query),
include: availableModels.filter(item => item.model !== model)
});
const { searchFields } = searchHelper(query);
const { offset, limit } = paginationValues(query);
const val = query.article || query.tag;
const searchResult = await model.search(val, limit, offset, searchFields);
return searchResult;
} catch (error) {
return serverError(res);
}
}

/**
* @param {Object} req
* @param {Object} res
* @param {String} model model to perform search query on
* @returns {Object} object with result of seach
*/
static async globalSearch(req, res) {
const { global, page, pageItems } = req.query;
const tables = [Article, Tag];
const { offset, limit } = paginationValues(req.query);
const data = await tables.map(model => model.search(global, limit, offset));
const userSearch = await Search.modelSearch(
{ user: global, page, pageItems },
res,
User
);
const allResults = await Promise.all(data);
return serverResponse(res, 200, {
userSearch,
articleSearch: {
count: allResults[0].count,
results: allResults[0].rows
},
tagSearch: {
count: allResults[1].count,
results: allResults[1].rows
}
});
}
}

export default Search;
64 changes: 64 additions & 0 deletions server/database/migrations/create-search.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
const vectorName = '_search';

const searchObjects = {
Articles: ['title', '"articleBody"', 'description'],
Tags: ['name']
};

export default {
up: queryInterface => queryInterface.sequelize.transaction(t => Promise.all(
Object.keys(searchObjects).map(table => queryInterface.sequelize
.query(
`
ALTER TABLE "${table}" ADD COLUMN ${vectorName} TSVECTOR;
`,
{ transaction: t }
)
.then(() => queryInterface.sequelize.query(
`
UPDATE "${table}" SET ${vectorName} =
to_tsvector('english', ${searchObjects[table].join(
" || ' ' || "
)});
`,
{ transaction: t }
))
.then(() => queryInterface.sequelize.query(
`
CREATE INDEX "${table}_search" ON "${table}" USING gin(${vectorName});
`,
{ transaction: t }
))
.then(() => queryInterface.sequelize.query(
`
CREATE TRIGGER "${table}_vector_update"
BEFORE INSERT OR UPDATE ON "${table}"
FOR EACH ROW EXECUTE PROCEDURE tsvector_update_trigger(${vectorName},
'pg_catalog.english', ${searchObjects[table].join(', ')});
`,
{ transaction: t }
)))
)),

down: queryInterface => queryInterface.sequelize.transaction(t => Promise.all(
Object.keys(searchObjects).map(table => queryInterface.sequelize
.query(
`
DROP TRIGGER "${table}_vector_update" ON "${table}";
`,
{ transaction: t }
)
.then(() => queryInterface.sequelize.query(
`
DROP INDEX "${table}_search";
`,
{ transaction: t }
))
.then(() => queryInterface.sequelize.query(
`
ALTER TABLE "${table}" DROP COLUMN ${vectorName};
`,
{ transaction: t }
)))
))
};
15 changes: 15 additions & 0 deletions server/database/models/article.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,21 @@ export default (sequelize, DataTypes) => {
},
{}
);

Article.getSearchVector = () => '_search';

Article.search = async (query, limit, offset) => {
query = sequelize.getQueryInterface().escape(query);

const result = await sequelize.query(
`SELECT slug, title, description, image, "articleBody", "likesCount",
"dislikesCount", "publishedAt" FROM "Articles" WHERE
"${Article.getSearchVector()}" @@ plainto_tsquery('english', ${query})
ORDER BY "createdAt" LIMIT ${limit} OFFSET ${offset}`
);
return { count: result[1].rowCount, rows: result[0] };
};

Article.findById = async (id) => {
const article = await Article.findOne({ where: { id } });
return article;
Expand Down
11 changes: 2 additions & 9 deletions server/database/models/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
/* istanbul ignore file */

import fs from 'fs';
import path from 'path';
import Sequelize from 'sequelize';
Expand All @@ -9,16 +7,11 @@ import * as dbData from '../config/config';
config();

const base = path.basename(__filename);
const env = process.env.NODE_ENV || 'development';
const env = process.env.NODE_ENV;
const db = {};
const dbUrl = dbData[env];

let sequelize;
if (dbUrl.use_env_variable) {
sequelize = new Sequelize(process.env[dbUrl.use_env_variable], dbUrl);
} else {
sequelize = new Sequelize(dbUrl.database, dbUrl.user, dbUrl.password, dbUrl);
}
const sequelize = new Sequelize(process.env[dbUrl.use_env_variable], dbUrl);

fs.readdirSync(__dirname)
.filter(
Expand Down
13 changes: 13 additions & 0 deletions server/database/models/tag.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,19 @@ export default (sequelize, DataTypes) => {
{}
);

Tag.getSearchVector = () => '_search';

Tag.search = async (query, limit, offset) => {
query = sequelize.getQueryInterface().escape(query);

const result = await sequelize.query(
`SELECT name FROM "Tags" WHERE
"${Tag.getSearchVector()}" @@ plainto_tsquery('english', ${query})
ORDER BY "createdAt" LIMIT ${limit} OFFSET ${offset}`
);
return { count: result[1].rowCount, rows: result[0] };
};

Tag.associate = (models) => {
Tag.belongsToMany(models.Article, {
foreignKey: 'tagId',
Expand Down
14 changes: 14 additions & 0 deletions server/database/models/user.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { Op } from 'sequelize';

export default (sequelize, DataTypes) => {
const User = sequelize.define('User', {
firstName: {
Expand Down Expand Up @@ -190,6 +192,18 @@ export default (sequelize, DataTypes) => {
);
return user[0];
};

User.search = async (_, limit, offset, searchFields) => {
const users = await User.findAndCountAll({
where: {
[Op.or]: searchFields
},
limit,
offset
});
return users;
};

User.associate = (models) => {
User.hasMany(models.Session, {
foreignKey: 'userId',
Expand Down
2 changes: 1 addition & 1 deletion server/helpers/paginationHelper.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ const paginationValues = ({ page, pageItems }) => {
}

const offset = (parsedPage - 1) * parsedPageItems;
const limit = offset + parsedPageItems;
const limit = parsedPageItems;
return { offset, limit };
};

Expand Down
4 changes: 2 additions & 2 deletions server/helpers/searchHelper.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ const { User, Tag, Article } = models;
* @returns {JSON} Search parameters
*/
const searchHelper = (req) => {
const queryObject = Object.keys(req.query);
const queryObject = Object.keys(req);
const allowedQueries = ['article', 'user', 'tag'];
const userQuery = allowedQueries.find(query => queryObject.includes(query));
const searchQuery = { [Op.iLike]: `%${req.query[`${userQuery}`]}%` };
const searchQuery = { [Op.iLike]: `%${req[`${userQuery}`]}%` };

const dbQueryFields = {
article: {
Expand Down
4 changes: 2 additions & 2 deletions test/helpers/paginationHelper.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ describe('Pagination values function test', () => {
const values = paginationValues({ page: 2, pageItems: 7 });
expect(values).to.be.an('object');
expect(values).to.have.property('offset', 7);
expect(values).to.have.property('limit', 14);
expect(values).to.have.property('limit', 7);
});
});
});
Expand All @@ -33,7 +33,7 @@ context('when the pagination function is called with characters', () => {
const values = paginationValues({ page: 4, pageItems: 't' });
expect(values).to.be.an('object');
expect(values).to.have.property('offset', 30);
expect(values).to.have.property('limit', 40);
expect(values).to.have.property('limit', 10);
});

it('returns the default values for offset passed a character', () => {
Expand Down
31 changes: 24 additions & 7 deletions test/search/search.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,11 @@ describe('Search Tests', () => {
});

context('when the user enters a query for a nonexisting user', () => {
it('returns a 404 error', async () => {
it('returns a 200 error', async () => {
const response = await chai
.request(app)
.get(`${BASE_URL}/search/?user=unknownPersonality`);
expect(response).to.have.status(404);
expect(response.body.error).to.equal(
'your query did not match any results'
);
expect(response).to.have.status(200);
});
});

Expand All @@ -48,8 +45,28 @@ describe('Search Tests', () => {
const response = await chai
.request(app)
.get(`${BASE_URL}/search/?article=thiscandefinitelynotWorkYEt`);
expect(response).to.have.status(404);
expect(response).to.have.status(200);
expect(response.body).to.be.an('object');
});
});

context('when the user enters a global search query', () => {
it('returns the search results', async () => {
const response = await chai
.request(app)
.get(`${BASE_URL}/search/?global=j`);
expect(response).to.have.status(200);
expect(response.body).to.be.an('object');
expect(response.body.userSearch.count).to.not.equal('0');
});
});

context('when the user enters an unkwown global search query', () => {
it('returns a body', async () => {
const response = await chai
.request(app)
.get(`${BASE_URL}/search/?global=ThisWillDfientyelyUknwkjd`);
expect(response).to.have.status(200);
});
});

Expand All @@ -59,7 +76,7 @@ describe('Search Tests', () => {
const request = {};
try {
sinon.stub(response, 'status').returnsThis();
await Search.searchSingleModel(request, response, 'model');
await Search.modelSearch(request, response, 'model');
} catch (error) {
expect(response).to.have.status(500);
}
Expand Down

0 comments on commit 23ba6d6

Please sign in to comment.