-
Notifications
You must be signed in to change notification settings - Fork 390
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Implement new search box, new API routes for searching and search pages.
- Loading branch information
1 parent
2655261
commit 8ddef9c
Showing
20 changed files
with
1,592 additions
and
369 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,56 @@ | ||
exports.up = async (pgm) => { | ||
await pgm.createTable('searches', { | ||
id: { | ||
type: 'uuid', | ||
primaryKey: true, | ||
notNull: true, | ||
default: pgm.func('gen_random_uuid()'), | ||
}, | ||
search_term: { | ||
type: 'varchar(255)', | ||
notNull: true, | ||
}, | ||
search_count: { | ||
type: 'integer', | ||
notNull: true, | ||
default: 0, | ||
}, | ||
created_at: { | ||
type: 'timestamp with time zone', | ||
notNull: true, | ||
default: pgm.func("(now() at time zone 'utc')"), | ||
}, | ||
updated_at: { | ||
type: 'timestamp with time zone', | ||
notNull: true, | ||
default: pgm.func("(now() at time zone 'utc')"), | ||
}, | ||
}); | ||
|
||
await pgm.createConstraint('searches', 'search_term_unique', { | ||
unique: 'search_term', | ||
}); | ||
|
||
await pgm.db.query(` | ||
CREATE INDEX idx_search ON contents USING GIN(to_tsvector('portuguese', title)); | ||
`); | ||
|
||
await pgm.db.query(` | ||
ALTER TABLE contents | ||
ADD COLUMN ts tsvector | ||
GENERATED ALWAYS AS ( | ||
setweight(to_tsvector('portuguese', coalesce(title, '')), 'A') || | ||
setweight(to_tsvector('portuguese', coalesce(body, '')), 'B') | ||
) STORED; | ||
`); | ||
}; | ||
|
||
exports.down = async (pgm) => { | ||
await pgm.dropConstraint('searches', 'search_term_unique', { ifExists: true }); | ||
|
||
await pgm.dropTable('searches', { ifExists: true }); | ||
|
||
await pgm.dropIndex('contents', 'idx_search', { ifExists: true }); | ||
|
||
await pgm.dropColumn('contents', 'ts', { ifExists: true }); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,217 @@ | ||
import database from 'infra/database'; | ||
|
||
function formatMilliseconds(milliseconds) { | ||
const seconds = milliseconds / 1000; | ||
const formattedSeconds = seconds.toFixed(3); | ||
const plural = seconds !== 1 ? 's' : ''; | ||
|
||
return `${formattedSeconds} segundo${plural}`; | ||
} | ||
|
||
function cleanSearchTerm(searchTerm) { | ||
const wordsAndNumbers = searchTerm.match(/[a-zA-Z]+|\d+/g); | ||
const filteredWordsAndNumbers = wordsAndNumbers.map((item) => { | ||
return item.replace(/[^a-zA-Z0-9]/g, ''); | ||
}); | ||
const separatedWords = filteredWordsAndNumbers | ||
.join(' ') | ||
.replace(/([a-z])([A-Z])/g, '$1 $2') | ||
.split(' ') | ||
.filter(Boolean); | ||
return separatedWords.join(':* | '); | ||
} | ||
|
||
function generateTsQuery(cleanedSearchTerm) { | ||
return `to_tsquery('portuguese', websearch_to_tsquery('portuguese','${cleanedSearchTerm}')::text || ':*')`; | ||
} | ||
|
||
function generateOrderByClause(sortBy, tsQuery) { | ||
let orderByClause = ''; | ||
if (sortBy === 'date') { | ||
orderByClause = 'ORDER BY created_at DESC'; | ||
} else if (sortBy === 'relevance') { | ||
orderByClause = `ORDER BY ts_rank(ts, ${tsQuery}) DESC`; | ||
} else if (sortBy === 'old') { | ||
orderByClause = 'ORDER BY created_at ASC'; | ||
} | ||
return orderByClause; | ||
} | ||
|
||
async function findAll({ searchTerm, sortBy = 'date', page = 1, perPage = 30 }) { | ||
const separatedWords = cleanSearchTerm(searchTerm); | ||
|
||
const tsQuery = generateTsQuery(separatedWords); | ||
|
||
const offset = (page - 1) * perPage; | ||
|
||
const totalCountQuery = { | ||
text: ` | ||
SELECT COUNT(*) | ||
FROM contents | ||
WHERE to_tsvector('portuguese', title) @@ ${tsQuery} | ||
AND status = 'published'; | ||
`, | ||
}; | ||
|
||
const searchQuery = { | ||
text: ` | ||
WITH content_window AS ( | ||
SELECT | ||
COUNT(*) OVER()::INTEGER as total_rows, | ||
id | ||
FROM | ||
contents | ||
WHERE | ||
to_tsvector('portuguese', contents.title) @@ ${tsQuery} | ||
AND contents.status = 'published' | ||
${generateOrderByClause(sortBy, tsQuery)} | ||
LIMIT $1 OFFSET $2 | ||
) | ||
SELECT | ||
contents.id, | ||
contents.owner_id, | ||
contents.parent_id, | ||
contents.slug, | ||
contents.title, | ||
contents.status, | ||
contents.source_url, | ||
contents.created_at, | ||
contents.updated_at, | ||
contents.published_at, | ||
contents.deleted_at, | ||
contents.path, | ||
users.username as owner_username, | ||
tabcoins_count.total_balance as tabcoins, | ||
tabcoins_count.total_credit as tabcoins_credit, | ||
tabcoins_count.total_debit as tabcoins_debit, | ||
( | ||
SELECT COUNT(*) | ||
FROM contents as children | ||
WHERE children.path @> ARRAY[contents.id] | ||
AND children.status = 'published' | ||
) as children_deep_count | ||
FROM | ||
contents | ||
INNER JOIN | ||
content_window ON contents.id = content_window.id | ||
INNER JOIN | ||
users ON contents.owner_id = users.id | ||
LEFT JOIN LATERAL get_content_balance_credit_debit(contents.id) tabcoins_count ON true | ||
WHERE | ||
contents.status = 'published' | ||
AND | ||
to_tsvector('portuguese', contents.title) @@ ${tsQuery} | ||
${generateOrderByClause(sortBy, tsQuery)} | ||
`, | ||
values: [perPage, offset], | ||
}; | ||
|
||
const startTime = performance.now(); | ||
|
||
const searchResults = await database.query(searchQuery); | ||
|
||
if (searchResults.rows.length > 0) { | ||
const updateTrendsQuery = { | ||
text: ` | ||
INSERT INTO searches (search_term, search_count, updated_at) | ||
VALUES ($1, 1, CURRENT_TIMESTAMP) | ||
ON CONFLICT (search_term) | ||
DO UPDATE SET search_count = searches.search_count + 1, updated_at = CURRENT_TIMESTAMP | ||
WHERE searches.search_term = $1 AND (CURRENT_TIMESTAMP - searches.updated_at) > INTERVAL '3 minutes'; | ||
`, | ||
values: [searchTerm], | ||
}; | ||
|
||
await database.query(updateTrendsQuery); | ||
|
||
const totalCountResult = await database.query(totalCountQuery); | ||
const totalResults = parseInt(totalCountResult.rows[0].count); | ||
|
||
const firstPage = 1; | ||
const lastPage = Math.ceil(totalResults / perPage); | ||
const nextPage = page >= lastPage ? null : parseInt(page) + 1; | ||
const previousPage = page <= 1 ? null : page - 1; | ||
|
||
const pagination = { | ||
currentPage: parseInt(page), | ||
totalRows: totalResults, | ||
perPage: parseInt(perPage), | ||
firstPage: firstPage, | ||
nextPage: nextPage, | ||
previousPage: previousPage, | ||
lastPage: lastPage, | ||
}; | ||
|
||
const endTime = performance.now(); | ||
const searchRuntime = endTime - startTime; | ||
|
||
return { | ||
searchRuntime: formatMilliseconds(searchRuntime), | ||
pagination, | ||
results: searchResults.rows, | ||
}; | ||
} | ||
} | ||
|
||
async function findAllTrends() { | ||
const startTime = performance.now(); | ||
|
||
const query = { | ||
text: ` | ||
SELECT * | ||
FROM searches | ||
WHERE updated_at >= CURRENT_DATE - INTERVAL '7 days' | ||
AND search_count >= 20 | ||
ORDER BY search_count DESC | ||
LIMIT 10; | ||
`, | ||
}; | ||
|
||
const res = await database.query(query); | ||
const endTime = performance.now(); | ||
const searchRuntime = endTime - startTime; | ||
|
||
const results = res.rows; | ||
|
||
return { | ||
searchRuntime: formatMilliseconds(searchRuntime), | ||
results, | ||
}; | ||
} | ||
|
||
async function findAllSuggestions(partialTerm) { | ||
const startTime = performance.now(); | ||
|
||
const separatedWords = cleanSearchTerm(partialTerm); | ||
|
||
const tsQuery = generateTsQuery(separatedWords); | ||
|
||
const query = { | ||
text: ` | ||
SELECT | ||
* | ||
FROM searches s | ||
WHERE to_tsvector('portuguese', s.search_term) @@ ${tsQuery} | ||
ORDER BY s.search_count DESC NULLS LAST | ||
LIMIT 10 | ||
`, | ||
}; | ||
|
||
const res = await database.query(query); | ||
const endTime = performance.now(); | ||
const searchRuntime = endTime - startTime; | ||
|
||
const results = res.rows; | ||
|
||
return { | ||
searchRuntime: formatMilliseconds(searchRuntime), | ||
results, | ||
}; | ||
} | ||
|
||
export default Object.freeze({ | ||
findAll, | ||
findAllTrends, | ||
findAllSuggestions, | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,55 @@ | ||
import nextConnect from 'next-connect'; | ||
|
||
import { NotFoundError } from 'errors'; | ||
import authentication from 'models/authentication'; | ||
import authorization from 'models/authorization'; | ||
import cacheControl from 'models/cache-control'; | ||
import controller from 'models/controller'; | ||
import search from 'models/search'; | ||
import user from 'models/user'; | ||
import validator from 'models/validator'; | ||
|
||
export default nextConnect({ | ||
attachParams: true, | ||
onNoMatch: controller.onNoMatchHandler, | ||
onError: controller.onErrorHandler, | ||
}) | ||
.use(controller.injectRequestMetadata) | ||
.use(authentication.injectAnonymousOrUser) | ||
.use(controller.logRequest) | ||
.get(cacheControl.swrMaxAge(10), getValidationHandler, getHandler); | ||
|
||
function getValidationHandler(request, response, next) { | ||
const cleanValues = validator(request.query, { | ||
q: 'required', | ||
page: 'optional', | ||
per_page: 'optional', | ||
sort: 'optional', | ||
}); | ||
|
||
request.query = cleanValues; | ||
|
||
next(); | ||
} | ||
|
||
async function getHandler(request, response) { | ||
const userTryingToGet = user.createAnonymous(); | ||
|
||
const { q, page, per_page, sort } = request.query; | ||
|
||
const contentFound = await search.findAll(q, page, per_page, sort); | ||
|
||
if (!contentFound) { | ||
throw new NotFoundError({ | ||
message: `A busca não foi encontrado no sistema.`, | ||
action: 'Verifique se o "query" está digitado corretamente.', | ||
stack: new Error().stack, | ||
errorLocationCode: 'CONTROLLER:CONTENT:GET_HANDLER:SEARCH_NOT_FOUND', | ||
key: 'q', | ||
}); | ||
} | ||
|
||
const secureOutputValues = authorization.filterOutput(userTryingToGet, 'read:content', contentFound); | ||
|
||
return response.status(200).json(secureOutputValues); | ||
} |
Oops, something went wrong.