Skip to content

Commit

Permalink
#167940543 Implement Global Search (#53)
Browse files Browse the repository at this point in the history
* feature=[167190545]: Add search functionality
- Add support for searching for items to the database
- Add support for case insensitive searches

[Delivers #167190545]

* feature-[167940543]: Implement global search
- Extend search feature to allow searching all models with one query

[Delivers #167940543]
  • Loading branch information
chialuka authored and dinobi committed Sep 11, 2019
1 parent f2742f1 commit fa07fac
Show file tree
Hide file tree
Showing 11 changed files with 613 additions and 83 deletions.
101 changes: 58 additions & 43 deletions server/controllers/Search.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { Op } from 'sequelize';
import {
paginationValues,
serverResponse,
Expand All @@ -8,7 +7,9 @@ import {
} from '../helpers';
import models from '../database/models';

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

/**
* @class
Expand All @@ -22,23 +23,29 @@ class Search {
* @returns {JSON} Json object
*/
static async findQuery(req, res) {
const { page, pageItems } = req.query;
const { page, pageItems, global } = req.query;
if (global) return Search.globalSearch(req, res);
const searchCategories = searchCategorizer(req.query);
if (!searchCategories) {
return serverResponse(res, 400, { error: 'no search query entered' });
if (!searchCategories && Object.keys(req.query).includes('article')) {
const { offset, limit } = paginationValues(req.query);
const articles = await Article.findByPage(offset, limit, models);
return serverResponse(res, 200, articles);
}
const {
modelToSearch: { model }
} = searchCategories;
const searchResponse = await Search.modelSearch(req.query, res, model);
const pageDetails = pageCounter(searchResponse.count, page, pageItems);
const { count, results } = await Search.modelSearch(req.query, res, model);
const pageDetails = pageCounter(count, page, pageItems);
const { totalPages, itemsOnPage, parsedPage } = pageDetails;

return serverResponse(res, 200, {
currentPage: parsedPage,
totalPages,
itemsOnPage,
data: searchResponse
data: {
count,
results
}
});
}

Expand All @@ -51,46 +58,54 @@ class Search {
static async modelSearch(query, res, model) {
try {
const { searchFields } = searchCategorizer(query);
const isArchived = model === Article ? { isArchived: false } : '';
let attributes;
if (model === User) {
attributes = [
'firstName',
'lastName',
'userName',
'identifiedBy',
'avatarUrl',
'bio',
'followingsCount',
'followersCount'
];
} else if (model === Article) {
attributes = [
'slug',
'title',
'description',
'image',
'articleBody',
'likesCount',
'dislikesCount',
'publishedAt'
];
} else {
attributes = ['name'];
}
const searchResult = await model.findAndCountAll({
where: {
[Op.or]: searchFields,
...isArchived
},
attributes,
...paginationValues(query)
});
const { offset, limit } = paginationValues(query);
const articleOrTag = query.article || query.tag || query.category;
const searchResult = await model.search(
articleOrTag,
limit,
offset,
searchFields,
models
);
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, Category];
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].results
},
tagSearch: {
count: allResults[1].count,
results: allResults[1].results
},
categorySearch: {
count: allResults[2].count,
results: allResults[2].results
}
});
}
}

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

const searchObjects = {
Articles: ['title', '"articleBody"', 'description'],
Tags: ['name'],
Categories: ['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 }
)))
))
};
122 changes: 122 additions & 0 deletions server/database/models/article.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import SequelizeSlugify from 'sequelize-slugify';
import { Op } from 'sequelize';

export default (sequelize, DataTypes) => {
const Article = sequelize.define(
Expand Down Expand Up @@ -82,6 +83,127 @@ export default (sequelize, DataTypes) => {
},
{}
);

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

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

const queryResult = await sequelize.query(
`
SELECT "Article".*, "comments"."comment" AS "comment",
"comments->author"."firstName" AS "firstName",
"comments->author"."lastName" AS "lastName",
"comments->author"."userName" AS "userName",
"comments->author"."avatarUrl" AS "avatarUrl"
FROM (SELECT "Article"."id", "Article"."slug", "Article"."title",
"Article"."description", "Article"."image", "Article"."articleBody",
"Article"."likesCount", "Article"."dislikesCount",
"Article"."publishedAt" FROM "Articles" AS "Article" WHERE
"${Article.getSearchVector()}" @@ plainto_tsquery('english', ${query})
AND "Article"."isArchived" = false AND "Article"."publishedAt" IS NOT NULL
LIMIT ${limit} OFFSET ${offset})
AS "Article" LEFT OUTER JOIN "Comments" AS "comments"
ON "Article"."id" = "comments"."articleId" LEFT OUTER JOIN
"Users" AS "comments->author"
ON "comments"."userId" = "comments->author"."id"
`
);

if (!queryResult[0].length) return { count: 0, results: [] };

const formattedResponse = queryResult[0].map((item, index, array) => {
const comments = [];

if (item.comment) {
comments.push({
comment: item.comment,
author: {
firstName: item.firstName,
lastName: item.lastName,
userName: item.userName,
avatarUrl: item.avatarUrl
}
});
}

['comment', 'firstName', 'lastName', 'userName', 'avatarUrl'].forEach(
prop => delete item[prop]
);

item.comments = comments;
return array;
});

const items = formattedResponse[0];

const results = items.reduce((acc, value) => {
const key = value.id;
if (acc[key]) {
acc[key] = {
...acc[key],
comments: [...acc[key].comments, { ...value.comments[0] }]
};
} else {
acc[key] = { ...value };
}
return acc;
}, {});

return { count: queryResult[1].rowCount, results: [results] };
};

Article.findByPage = async (offset, limit, models) => {
const { count, rows } = await Article.findAndCountAll({
distinct: true,
where: {
isArchived: false,
publishedAt: {
[Op.ne]: null
}
},
attributes: [
'slug',
'title',
'description',
'image',
'articleBody',
'likesCount',
'dislikesCount',
'publishedAt'
],
limit,
offset,
include: [
{
model: models.Comment,
as: 'comments',
attributes: ['comment'],
include: [
{
model: models.User,
as: 'author',
attributes: [
'firstName',
'lastName',
'userName',
'avatarUrl',
'bio'
]
}
]
},
{
model: models.Tag,
as: 'tags',
attributes: ['name'],
through: { attributes: [] }
}
]
});
return { count, results: rows };
};

Article.findById = async (id) => {
const article = await Article.findOne({ where: { id } });
return article;
Expand Down
Loading

0 comments on commit fa07fac

Please sign in to comment.