- 1. ЧАСТЬ 1 — Создаем RESTful сервис
- 1.1. Поиск в вашем сервисе
- 1.2. Начало работы с Feathers
- 1.3. Добавление тестовой службы
- 1.4. Добавление полей в наш сервис
- 1.5. Добавление БД
- 1.6. Подтверждение структуры БД
- 2. ЧАСТЬ 2 — Создание поискового вектора
- 2.1. Тестирование крючка создания вектора
- 3. ЧАСТЬ 3 — Экспонирование поля для поиска
- 3.1. Добавление лучших данных
- 3.2. Белые списки нашего параметра запроса
- 3.3. Создание нашего хука
- 3.4. Очистка параметра поиска
- 3.5. Переопределение Feathers-Sequelize
- 3.6. Добавление ранга
- 3.7. Сортировка по рангу
Добавление Postgres Search в Node REST API
Зачем это нужно?
За 15 лет работы веб-разработчиком я создал бесчисленное количество сервисов с менее чем идеальными возможностями поиска. В первые дни WordPress и Drupal мы использовали операторы LIKE и объединяли строки. И хотя такие инструменты, как Mongo, имеют некоторые возможности поиска, ранжированные результаты поиска все еще было трудно развернуть. По мере развития интернета (и моих навыков) мы перегрузили ранжированный поиск такими инструментами, как Solr и Elastic. Но каждое из этих решений требует независимого сервиса — новые серверы, новые хранилища с сохранением состояния, новые затраты. Наконец, поиск как сервис был представлен такими замечательными компаниями, как Algolia, MeiliSearch и Bonsai. У каждой из этих платформ есть компромиссы. Хотя они позволяют избежать некоторых затрат на обслуживание, они также требуют, чтобы ваши данные покидали ваши системы. В регулируемых областях данных (fintech, edtech, healthtech) минимальные затраты на дополнительную безопасность могут оказаться слишком высокими для вас. Хуже того, по мере роста ваших данных растут и расходы, поскольку они «дважды размещают» данные в вашем стеке. В конечном итоге эти инструменты могут стать довольно дорогими, особенно если вам нужен простой ранжированный поиск по одной-двум таблицам.
Чего-то хорошего «достаточно»
Во многих стартапах, находящихся на стадии роста, данные зачастую сложнее, чем с ними может справиться простой LIKE mashing, но услуга не настолько сложна или прибыльна, чтобы требовать управляемого или развернутого сервиса. Что же делать?
PostgreSQL поиск в помощь!
Если вы собираете БД на основе SQL, то выбор PostgreSQL — отличный выбор, поскольку он предлагает множество встроенных функций поиска, которые могут покрыть этот разрыв между LIKE и Elastic. Многие, многие статьи рассказывают о настройке этих запросов или материализованных представлений. Моя любимая статья написана Рейчел Белайд. Но очень немногие предлагают примеры того, как развернуть эти возможности в настоящем сервисе.
Именно этим мы и займемся в этой серии.
1. ЧАСТЬ 1 — Создаем RESTful сервис
1.1. Поиск в вашем сервисе
Существует два основных способа развертывания поиска внутри сервиса, которые мы рассмотрим в этом руководстве.
- Добавить простой вектор поиска к одной таблице. Обеспечение лучшего поиска в одной таблице путем объединения нескольких полей в текстовый вектор с возможностью поиска.
- Добавление сложного вектора поиска, объединяющего несколько таблиц. Обеспечение лучшего поиска в сложном наборе JOIN с помощью автоматически обновляемого материализованного представления и вектора поиска.
В этом учебнике мы начнем с первого варианта.
Мнения об инструментах
За последнее десятилетие я создал множество RESTful-сервисов. При корпоративном переходе от локальных к облачным и микросервисным архитектурам возникли три повторяющиеся закономерности.
- Во-первых, «ожидание ввода-вывода» встречается повсеместно. Именно поэтому Node и Go развивались так быстро, а Ruby и PHP — медленнее. Это не значит, что они все еще не используются, но они не являются основными для RESTful API и микросервисов. Даже если бы ожидание ввода-вывода не было такой проблемой, в Node было вложено столько средств, что теперь он быстрее многих многопоточных альтернатив, даже при выполнении действий, требующих больших затрат процессора, таких как шифрование.
- Во-вторых, все шаблоны RESTful на 90% одинаковы. Редко реализуется HATEOAS, но почти все остальное становится стандартизированным.
- И в-третьих, базы данных не должны иметь значения для конечной точки REST. Я отдаю и получаю JSON. Меня не должно волновать, как он хранится. Это должен решать архитектор, основываясь на шаблонах запросов и доступа. Возможность работать с несколькими типами БД имеет значительные преимущества.
По этим трем причинам я влюбился в NodeJS-фреймворк под названием FeathersJS. Это легкий фреймворк на базе ExpressJS, который обеспечивает универсальную модель данных для нескольких БД, повторяющиеся и многократно используемые шаблоны REST и почти никаких дополнительных накладных расходов от Express. В отличие от таких фреймворков, как Sails или Nest, сервисы Feathers работают с микросервисным REST в качестве шаблона по умолчанию, отказываясь от грубости типичного MVC и заменяя его предсказуемыми цепочками промежуточного ПО. Feathers позволяет легко ответить на следующие вопросы по умолчанию:
- Правильно ли поступил запрос?
- Манипулируем ли мы запросом до того, как он попадет в БД?
- Если БД прислала нам что-то в ответ, обрабатываем ли мы это перед возвратом?
Самое главное, Feathers не позволяет усложнять код с помощью неявных паттернов, декораторов и чрезмерно связанного наследования. Вы все еще можете написать плохой код, но запах кода будет более очевидным и явным.
В этом учебнике мы будем использовать FeathersJS для нашей основной библиотеки. Мы также немного поработаем с Sequelize и KnexJS. Если вы предпочитаете сырой Express, вы можете довольно легко адаптировать хуки Feathers в промежуточное ПО Express, если вы решите это сделать.
#feathersjs
1.2. Начало работы с Feathers
-
Убедитесь, что у вас установлены NodeJS и npm.
-
Установите ваши зависимости
npm install @feathersjs/cli -g mkdir search-test cd search-test feathers generate app
-
Выберите следующее
$ Do you want to use JavaScript or TypeScript: TypeScript $ Project name: search-test $ Description: Testing Search in Postgres $ What folder should the source files live in: src $ Which package manager are you using (has to be installed globally): npm $ What type of API are you making: REST $ Which testing framework do you prefer: Mocha + assert $ This app uses authentication: No
-
Запустите ваше приложение
npm start
На этом этапе вы должны увидеть следующее:
info: Feathers application started on http://localhost:3030
А если вы перейдете на http://localhost:3030, то увидите логотип с перьями.
1.3. Добавление тестового сервиса
-
Добавьте RESTful сервис «books»
feathers generate service
ПРИМЕЧАНИЕ: То, что мы попросили сделать feathers здесь, это создать «сервис». Feathers определяет сервисы как объекты/классы, которые реализуют методы и обычно связаны с конкретной сущностью RESTful и конкретной таблицей или коллекцией БД. Методы сервиса — это предопределенные методы CRUD. Это то, что дает Feathers его силу — универсальный CRUD для всех типов БД или пользовательских источников данных.
-
Выберите следующее
$ What kind of service is it?: Sequelize $ What is the name of the service?: books $ Which path should the service be registered on?: /books $ Which database are you connecting to?: PostgreSQL $ What is the database connection string?: postgres://postgres:@localhost:5432/feathers_postgresql_search
1.4. Добавление полей в наш Сервис
-
Откройте
/src/models/books.model.ts
и измените его следующим образом.Сначала извлеките объект Books Model в виде
export const BooksModel = { title: { type: DataTypes.STRING, }, author: { type: DataTypes.STRING, }, description: { type: DataTypes.TEXT, }, isbn: { type: DataTypes.TEXT, } published: { type: DataTypes.DATEONLY } } const books = sequelizeClient.define('books', BooksModel,...)
Теперь мы можем получить доступ к схеме из других файлов.
-
Добавьте поле вектора поиска.
Здесь мы добавляем отдельный столбец в нашу конечную таблицу DB, который будет обеспечивать вектор и индекс для нашего поиска.
export const BooksModel = { // ... search_vector: { type: 'tsvector' } }
Это создаст столбец TSVECTOR в вашей БД Postgres. Обратите внимание, что тип в этом столбце отображается как строка. Это связано с тем, что Sequelize, хотя и поддерживает tsvectors, пока не предоставляет для них типы TypeScript.
1.5. Добавление БД
-
Убедитесь, что ваше подключение к Postgres правильно указано в
/config/default.json
.-
Если вы хотите запустить Postgres локально через Docker, добавьте следующее в
docker-compose.yml
.version: '3.8' services: # # This is the postgres docker DB available at port 5432 # # - This only for local usage and has no bearing on CloudSQL # # - When referencing the db from a compose container, use database:5432 database: image: "postgres:10.16" environment: - POSTGRES_USER=unicorn_user - POSTGRES_PASSWORD=magical_password - POSTGRES_DB=rainbow_database volumes: - database-data:/var/lib/postgresql/data/ ports: - "5432:5432" volumes: database-data:
-
В терминале запустите
docker-compose up --force-recreate --build
, и вы каждый раз будете получать свежее приложение для перьев и БД Postgres. -
Если вы используете контейнер docker, то строка подключения будет иметь вид
postgres://unicorn_user:magical_password@localhost:5432/rainbow_database
.
-
-
Убедитесь, что система загрузится, запустив
npm start
илиnpm run dev
в новой вкладке (после запуска Docker или Postgres).Если ваша система работает правильно, вы должны увидеть
info: Feathers application started on http://localhost:3030
.Если соединение с БД установлено, вы можете нажать
http://localhost:3030/books
и увидеть следующий JSON:{"total":0,"limit":10,"skip":0,"data":[]}
1.6. Подтвердите структуру вашей БД
Feathers Sequelize автоматически синхронизирует структуру БД с новой таблицей при загрузке. Но мы можем подтвердить наличие наших полей простым curl-запросом к нашему REST API.
curl --location --request POST 'http://localhost:3030/books'
--header 'Content-Type: application/json'
--data-raw '{
"title":"How I Built My House",
"author":"Bob Vila",
"description": "This book is a great book about building houses and family homes.",
"isbn": "12345678",
"published": "2021-12-15T20:28:03.578Z"
}'
Если вы снова нажмете http://localhost:3030/books
, должен отобразиться следующий JSON:
{
"total":1,
"limit":10,
"skip":0,
"data":[
{
"id":1,
"title": "How I Built My House",
"author": "Bob Vila",
"description": "This book is a great book about building houses and family homes.",
"isbn": "12345678",
"published": "2021-12-15",
"search_vector": null,
"createdAt": "2022-01-07T03:41:58.933Z",
"updatedAt": "2022-01-07T03:41:58.933Z"
}
]
}
Если на первых этапах у вас возникла ошибка, и какое-то поле отсутствует, попробуйте удалить всю таблицу и позволить Feathers восстановить ее с нуля.
2. ЧАСТЬ 2 — Создание вектора поиска
Как уже упоминалось, существует множество статей, описывающих особенности создания в Postgres tsvector для ранжированного поиска. Пример см. здесь. Мы хотим запустить оператор UPDATE
после модификации любой строки в нашем сервисе /books
. Это означает, что любой POST, PUT или PATCH должен перестроить вектор для этого ряда. Sequelize предлагает транзакционные крючки, но они могут быть сложными при пакетной записи. В контексте Feathers лучше всего создавать триггер непосредственно в SQL или оставить логику в Feathers hook
. Sequelize — это уродливый промежуточный вариант, который жестко привязывает наш поиск к ORM, а не к API или таблице БД.
Триггеры Postgres сложнее, поэтому мы будем использовать Feathers hook
. Хуки — это специфические, асинхронные, промежуточные функции, которые сопоставлены с каждым методом и путем Express. Например, в /src/services/books/books.hooks.ts
можно добавить следующее:
before: {
...
find: [(context)=>console.log('This is the /books context object:', context)],
...
}
Для каждого запроса find (т.е. GET-запроса к /books/{id}
, где id равно null или пусто) мы будем запускать функцию hook, которая передает контекст перьев (модифицированный объект Express Request) и записывает его в консоль. Поскольку эта функция находится в массиве before
, она сработает до того, как промежуточное ПО вызовет Sequelize и попадет в БД. Хуки Before отлично подходят для модификации данных в соответствии со схемой БД или аутентификации заголовков и пользователей. Хуки After отлично подходят для удаления посторонних или конфиденциальных полей из исходящего ответа.
Вот наш хук, который вы можете поместить в src/services/books/tsquery-and-search.hook.ts
.
import { HookContext } from '@feathersjs/feathers';
import { GeneralError } from '@feathersjs/errors';
export const updateTheTSVector = (options:any) => async (ctx:HookContext)=>{
// prevent a developer from using this hook without a named column to search
if(!options.searchColumn) throw new GeneralError('TSVector hook cannot function without a searchColumn parameter.')
// gets the shared sequelize client
const sequelize = ctx.app.get('sequelizeClient');
const id = ctx.result.id;
// creates a list of all of the fields we want to search based on the inclusion of a "level" field in our Model.
// ts_rank allows us to set importance on four levels: A > B > C > D.
const fieldList = Object.keys(options.model).filter(k=>(options.model as any)[k].level && ['A','B','C','D'].includes((options.model as any)[k].level));
// Our query is an update statement that maps each appropriate field to a vector and then merges all the vectors for storage
const query = `
UPDATE "${ctx.path}" SET "${options.searchColumn}" = (`+
fieldList.map((v,i)=>{
return `setweight(to_tsvector($${i+1}), '${(options.model as any)[v].level}')`;
}).join(' || ')
+`) WHERE "id"=${id} RETURNING ${options.searchColumn};
`;
// we now await the query update and do a SQL-safe injection through the bind option in sequelize. This replaces the $1 and $2 etc. in the UPDATE statement with the values from our input data.
await sequelize.query(query,
{
bind: fieldList.map(v=>ctx.result[v]),
type: QueryTypes.UPDATE
})
.then((r:any)=>{
// because we want see the vector in our result(not normal), we modify the outbound data by appending the updated search_vector field.
// set the result to the context object so we can share it with the user or hide it
ctx.result[options.searchColumn] = r[0][0][options.searchColumn];
})
// since the data has already been mutated/deleted, we shouldn't throw an error to the end user, but log it for internal tracking
.catch((e:any)=>console.error(e));
return ctx;
};
И мы добавим его к следующим после хука в файле books.hooks.ts
:
// add the Model so we can reference it in the hook
import { BooksModel } from '../../models/books.model';
after: {
all: [],
find: [],
get: [],
create: [updateTheTSVector({model:BooksModel, searchColumn:'search_vector'})],
update: [updateTheTSVector({model:BooksModel, searchColumn:'search_vector'})],
patch: [updateTheTSVector({model:BooksModel, searchColumn:'search_vector'})],
remove: []
}
ПРИМЕЧАНИЕ: мы дали себе поле опций хука под названием searchColumn
, которое позволяет нам повторно использовать этот хук в других местах, и мы ссылаемся непосредственно на Модель, так что ничто в этом хуке не является books
-специфичным.
2.1. Тестирование хука создания вектора
Давайте испытаем наш хук. Сначала нам нужно добавить поля ранжирования в объект Model. Вот пример:
title: {
type: DataTypes.STRING,
level: 'A'
},
author: {
type: DataTypes.STRING,
level: 'C'
},
description: {
type: DataTypes.TEXT,
level: 'B'
}
Это означает, что относительная сила для ранжирования результатов смотрит на title > description > author
. Чтобы было понятно, level
не является официальным параметром поля Sequelize, но мы используем его в нашем хуке, чтобы определить, какие поля включать в наш вектор, а какие игнорировать.
Теперь давайте снова запустим этот curl:
curl --location --request POST 'http://localhost:3030/books' --header 'Co application/json' --data-raw '{
"title":"How I Built My House",
"author":"Bob Vila",
"description": "This book is a great book about building houses and family homes.",
"isbn": "12345678",
"published": "2021-12-15T20:28:03.578Z"
}'
Теперь вы видите, что последняя строка имеет следующий вектор: 'bob':6C 'book':9B,13B 'build':15B 'built':3A 'famili':18B 'great':12B 'home':19B 'hous':5A,16B 'vila':7C
Поздравляем, теперь мы автоматически обновляем наш поисковый вектор! Вы можете подтвердить это также с помощью запросов PUT и PATCH.
В следующей статье мы добавим возможность использовать этот вектор из HTTP-запроса.
3. ЧАСТЬ 3 - Экспонирование поля для поиска
Это руководство является третьей частью нашей серии статей о добавлении поиска Postgres в RESTful API без использования операторов LIKE bruteforce или внешних инструментов. В предыдущей части мы рассмотрели добавление вектора поиска в нашу БД. Но добавление вектора поиска мало что даст, если мы не включим поиск по нему как потребитель API. Из-за того, как Sequelize создает запросы, это может оказаться немного сложным. Мы собираемся решить эту проблему с помощью нового хука.
3.1. Добавление лучших данных
Если вы возились с кодом в первой части, вы, вероятно, засеяли свою БД большим количеством тестовых запросов и простых объектов книг. Давайте добавим немного более качественных данных для наших тестовых сценариев. Удалите все оставшиеся строки из вашей БД Postgres или удалите таблицу и перезапустите перья.
Теперь выполните следующие три запроса curl:
curl --location --request POST 'http://localhost:3030/books'
--header 'Content-Type: application/json'
--data-raw '
{
"title":"Space: A Novel",
"author":"James A. Michener ",
"description": "Already a renowned chronicler of the epic events of world history, James A. Michener tackles the most ambitious subject of his career: space, the last great frontier. This astounding novel brings to life the dreams and daring of countless men and women - people like Stanley Mott, the engineer whose irrepressible drive for knowledge places him at the center of the American exploration effort; Norman Grant, the war hero and US senator who takes his personal battle not only to a nation but to the heavens; Dieter Kolff, a German rocket scientist who once worked for the Nazis; Randy Claggett, the astronaut who meets his destiny on a mission to the far side of the moon; and Cynthia Rhee, the reporter whose determined crusade brings their story to a breathless world.",
"isbn": "0812986768",
"published": "2015-07-07T00:00:00.000Z"
}';
curl --location --request POST 'http://localhost:3030/books'
--header 'Content-Type: application/json'
--data-raw '
{
"title":"A Concise History of the Netherlands",
"author":"James Kennedy",
"description": "The Netherlands is known among foreigners today for its cheese and its windmills, its Golden Age paintings and its experimentation in social policies such as cannabis and euthanasia. Yet the historical background for any of these quintessentially Dutch achievements is often unfamiliar to outsiders. This Concise History offers an overview of this surprisingly little-known but fascinating country. Beginning with the first humanoid settlers, the book follows the most important contours of Dutch history, from Roman times through to the Habsburgs, the Dutch Republic and the Golden Age. The author, a modernist, pays particularly close attention to recent developments, including the signature features of contemporary Dutch society. In addition to being a political history, this overview also gives systematic attention to social and economic developments, as well as in religion, the arts and the Dutch struggle against the water. The Dutch Caribbean is also included in the narrative.",
"isbn": "0521875889",
"published": "2017-08-24T00:00:00.000Z"
}';
curl --location --request POST 'http://localhost:3030/books'
--header 'Content-Type: application/json'
--data-raw '
{
"title":"Exploring Kennedy Space Center (Travel America's Landmarks)",
"author":"Emma Huddleston",
"description": "Gives readers a close-up look at the history and importance of Kennedy Space Center. With colorful spreads featuring fun facts, sidebars, a labeled map, and a Thats Amazing! special feature, this book provides an engaging overview of this amazing landmark.",
"isbn": "1641858540",
"published": "2019-08-01T00:00:00.000Z"
}';
Это добавит 3 реальные книги в нашу базу данных. Мы будем искать все три книги различными способами, чтобы проверить нашу новую возможность поиска. Если вы откроете БД, то увидите, что в столбце search_vector есть значительно большие векторы для работы. Для книги Эммы Хаддлстон мы получаем 'amaz':40B,51B 'america':6A 'book':44B 'center':4A,26B 'close':15B 'close-up':14B 'color':28B 'emma':9C 'engag':47B 'explor':1A 'fact':32B 'featur':30B,42B 'fun':31B 'give':11B 'histori': 20B 'huddleston':10C 'import':22B 'kennedi':2A,24B 'label':35B 'landmark':8A,52B 'look':17B 'map':36B 'overview':48B 'provid':45B 'reader':12B 'sidebar':33B 'space':3A,25B 'special':41B 'spread':29B 'that':39B 'travel':5A
.
3.2. Белые списки параметров запроса
Feathers запретит определенные параметры запроса, которые не внесены в белый список и не являются полями в модели сервиса. Мы хотим иметь возможность фильтровать с помощью обычного соответствия, например publication > 2018
.
Для этого наш возможный REST-запрос будет выглядеть как http://localhost:3030/books?published[$gt]=2016
.
Если вы введете этот запрос, вы должны увидеть только 2 результата, исключая Space: A Novel
. Это сила стандартных CRUD-операций Feathers и перевода запросов.
Но мы также фильтруем по ключевым словам поиска !Johnson & Kennedy & (space | history)
, что эквивалентно -Johnson and Kennedy and ( space or history )
, если вы предпочитаете поисковые слова. Это близко к синтаксису Google, но не точно.
Чтобы включить поиск, мы добавим новый параметр запроса, $search
, сделав наш запрос http://localhost:3030/books?published[$gt]=2016&$search=!Johnson & Kennedy & (space | history)
. Но помните, что URL не любят пробелы и скобки, поэтому давайте перекодируем его в %21Johnson%26Kennedy%26%28space%7Chistory%29
.
Теперь наш поисковый запрос выглядит следующим образом: http://localhost:3030/books?published[$gt]=2016&$search=%21Johnson%26Kennedy%26%28space%7Chistory%29
.
Если вы перейдете на эту конечную точку сейчас, то увидите Invalid query parameter $search
. Чтобы исправить это, перейдите в src/services/books/books.service.ts
и добавьте массив whitelist
следующим образом:
const options = {
Model: createModel(app),
paginate: app.get('paginate'),
whitelist: ['$search']
};
Теперь попробуйте снова! Вы должны увидеть колонка books.$search не существует
. Это хорошо... это означает, что наш параметр $search разрешен и мы можем очистить его в нашем хуке.
3.3. Создание нашего хука
Поскольку единственной комбинацией HTTP-глагола и пути, для которой мы хотим поддерживать $search, является FIND
, то именно там и будет находиться наш хук. И поскольку это только before
хук, поместите следующее в ваш файл books.hooks.ts
:
export default {
before:{
//...
find: [ modifyQueryForSearch({searchColumn:'search_vector'}),
//...
}
Обратите внимание, что мы используем то же имя searchColumn
, что и раньше.
Но этой функции не существует. Давайте теперь добавим импорт и заполнитель:
// books.hooks.ts
import { modifyQueryForSearch, updateTheTSVector } from './tsquery-and-search.hook';
// tsquery-and-search.hook.ts
export const modifyQueryForSearch = (options:any) => async(ctx:HookContext)=>{}
Теперь у нас есть хук, который ничего не делает, но находится в нужном месте.
3.4. Очистка параметра Search
Поскольку в нашей БД нет столбца $search
, мы хотим удалить этот параметр из нашего запроса и сохранить его на потом. Таким образом, sequelize не будет пытаться искать столбец search
в таблице books
. Добавьте в функцию следующее:
export const modifyQueryForSearch = (options:any) => async(ctx:HookContext)=>{
const params = ctx.params;
// NOTE: make sure to add whitelist: ['$search'] to the service options.
const search = params?.query?.$search;
// early exit if $search isn't a queryparameter so we can use normal sort and filter.
if(!search) return ctx;
// removes that parameter so we don't interfere with normal querying
delete ctx.params?.query?.$search;
}
Отлично, теперь, если мы нажмем http://localhost:3030/books?published[$gt]=2016&$search=%21Johnson%26Kennedy%26%28space%7Chistory%29
снова, мы должны снова увидеть наши 2 результата. Поиск не работает, но это не нарушает запрос.
3.5. Переопределение Feathers-Sequelize
Feathers-sequelize обычно берет наш params.query
и преобразует его в структуру, удобную для сиквелизации. Мы хотим изменить эту структуру так, чтобы наш SQL WHERE
оператор включал наши параметры поиска. Если вы изучите функцию _find
в node_modules/feathers-sequelize/lib/index.js
, то сможете увидеть, что она делает.
_find (params = {}) {
const { filters, query: where, paginate } = this.filterQuery(params);
const order = utils.getOrder(filters.$sort);
const q = Object.assign({
where,
order,
limit: filters.$limit,
offset: filters.$skip,
raw: this.raw,
distinct: true
}, params.sequelize);
if (filters.$select) {
q.attributes = filters.$select;
}
// etc
Как видите, мы можем переопределить параметры where
с помощью params.sequelize
, но это не глубокое слияние. Это не поможет. Но поскольку мы знаем, как формируется объект where
, мы можем повторить это оптом! Измените хук следующим образом:
export const modifyQueryForSearch = (options:any) => async(ctx:HookContext)=>{
//... params stuff
// build the where overrides ourselves
// this replicates how the _find function in Feathers-Sequelize works, so we can override because we can't merge the 'where' statements
const {query: where} = ctx.app.service(ctx.path).filterQuery(params);
// pass them into the sequelize parameter, which overrides Feathers, but we account for defaults above
params.sequelize = {
where:{
...where,
//... MODIFIACTIONS GO HERE
},
Если вы снова выполните запрос, результаты должны быть такими же.
Итак, что мы добавим в объект where
? Чтобы получить наш фильтр, мы хотим добавить дополнительный критерий. Наш конечный SQL-запрос должен выглядеть следующим образом:
Обратите внимание на добавление search_vector
и части to_tsquery
.
Итак, давайте начнем с Sequelize Op.and
, чтобы включить композит AND
в предложение WHERE
.
where:{
...where,
[Op.and]: //... MODIFIACTIONS GO HERE
},
Теперь мы знаем, что у нас есть функция to_tsquery
с входом, так что давайте сделаем ее:
where:{
...where,
[Op.and]: Sequelize.fn( `books.search_vector @@ to_tsquery`,'!Johnson&Kennedy&(space|history)')
)//... MODIFIACTIONS GO HERE
},
Очевидно, что мы не хотим жестко кодировать запрос, поэтому давайте извлечем его в качестве замены. Sequelize требует, чтобы мы ссылались на него как на литерал, чтобы он не был разобран неправильно.
params.sequelize = {
where:{
...where,
[Op.and]: Sequelize.fn( `books.search_vector @@ to_tsquery`, Sequelize.literal(':query'))
},
// replaces the string query from the parameters with a postgres safe string
replacements: { query: '!Johnson&Kennedy&(space|history)' }
}
Но мы также не хотим, чтобы этот хук был жестко привязан к books
или search_vector
. Давайте заменим их:
params.sequelize = {
where:{
...where,
[Op.and]: Sequelize.fn(
`${ctx.path}.${options.searchColumn} @@ to_tsquery`,
Sequelize.literal(':query')
)
},
// replaces the string query from the parameters with a postgres safe string
replacements: { query: '!Johnson&Kennedy&(space|history)' },
}
Теперь давайте разберемся со строкой запроса. Опять же, мы не хотим вводить ее в жесткий код, но мы также не хотим ожидать, что пользователь будет безупречен в своем поисковом запросе. К счастью, существует плагин npm, который преобразует типичные поисковые запросы в запросы Postgres tsquery. В терминале запустите npm i --save pg-tsquery
;
Импортируйте библиотеку с помощью import queryConverter from 'pg-tsquery';
в верхней части файла.
Поскольку мы хотим придать опциональность настройкам конвертера, мы можем сделать это опцией хука. Измените свой хук следующим образом:
export const modifyQueryForSearch = (options:any) => async(ctx:HookContext)=>{
// set defaults
options = {
conversionOptions:{},
searchColumn:'search_vector',
...options
};
const params = ctx.params;
// NOTE: make sure to add whitelist: ['$search'] to the service options.
const search = params?.query?.$search;
// early exit if $search isn't a query parameter so we can use normal sort and filter.
if(!search) return ctx;
// removes that parameter so we don't interfere with normal querying
delete ctx.params?.query?.$search;
// build the where overrides ourselves
// this replicates how the _find function in Feathers-Sequelize works, so we can override because we can't merge the 'where' statements
const {query: where} = ctx.app.service(ctx.path).filterQuery(params);
// pass them into the sequelize parameter, which overrides Feathers, but we account for defaults above
params.sequelize = {
where:{
...where,
// adds the search filter so it only includes matching responses
[Op.and]: Sequelize.fn(
`${ctx.path}.${options.searchColumn} @@ to_tsquery`,
Sequelize.literal(':query')
)
},
// replaces the string query from the parameters with a postgres safe string
replacements: { query: queryConverter(options.conversionOptions)(search) },
}
};
Вы можете проверить это, выполнив другой запрос: http://localhost:3030/books?published[$gt]=2016&$search=Dutch
, который должен вернуть только одну книгу, поскольку только в одном описании книги упоминается голландский язык.
3.6. Добавление ранга
Поисковая фильтрация на ts_vector по-прежнему очень мощная, но мы хотим иметь возможность ранжировать наши результаты повторяющимся образом. Для этого нам нужны две вещи: столбец, вычисляющий ранг, и оператор ORDER BY
в нашем SQL.
Наш конечный SQL должен выглядеть примерно так:
SELECT
*,
ts_rank(
books.search_vector,
to_tsquery('!Johnson&Kennedy&(space|history)')
) AS "rank"
FROM "books" AS "books"
WHERE
(books.search_vector @@ to_tsquery('!Johnson&Kennedy&(space|history)'))
AND
"books"."published" > '2016-01-01'
ORDER BY rank DESC;
Чтобы получить дополнительный столбец ts_rank
, нам понадобится еще один параметр Sequelize: attributes
. Атрибуты - это столбцы, которые выбираются Sequelize для возврата. По умолчанию включаются все поля. Feathers-sequelize поддерживает параметр запроса $select
, поэтому нам нужно защитить его при добавлении нашего кода ранжирования.
Добавьте следующую логику в ваш хук:
params.sequelize = {
//... from above example
}
//only bother with this if $select is used and has rank or no select at all (so rank is included by default)
const selected = filters.$select;
if(selected && selected.includes('rank') || !selected){
// remove the select so we can read it later as an attribute array
delete ctx.params?.query?.$select;
// then re-add it as a Sequelize column
const rankFunc = [ Sequelize.fn(
`ts_rank(${ctx.path}.${options.searchColumn}, to_tsquery`,
Sequelize.literal(':query)')), 'rank'
];
params.sequelize.attributes = selected
// if there are selected fields in the query, use the array structure and add our rank column,
? [...selected.filter((col:string)=>col!='rank'), rankFunc]
// if there are no selected fields, use the object structure that defaults to include all and then add our rank column
: {include: [rankFunc]};
Как и в случае с рангом, мы изменяем поле attribute
в params.sequelize
, указывая Feathers подтвердить все используемые параметры $select
, а также добавить $rank
, если это необходимо. rank
также добавляется в качестве поля по умолчанию, если нет опций $select
.
Если вы нажмете http://localhost:3030/books?published[$gt]=2016&$search=%21Johnson%26Kennedy%26%28space%7Chistory%29&$select[0]=id&$select[1]=title&$select[2]=rank
, вы увидите, что мы можем выбрать поля, включая звание.
3.7. Сортировка по рангу
Теперь, когда у нас есть колонка ранга, которая не мешает нашим опциям $select
, нам нужно иметь возможность сортировать по рангу, если мы хотим. В Feathers параметр $sort
используется для обозначения DESC
и ASC
по столбцам. Например, ?$sort[rank]=1
будет сортировать по возрастанию ранга (наименее связанные). Тогда как $sort[rank][]=-1&$sort[title][]=1
будет сортировать по рангу, а если ранги одинаковые, то в алфавитном порядке по названию.
Очевидно, что поскольку наш столбец рангов является инжектированным столбцом, он не добавляется автоматически в опции $sort
. Давайте сейчас это исправим. Внутри оператора if(selected && selected.includes('rank') || !selected){
if, но ниже : {include: [rankFunc]};
добавьте следующий код:
if(selected && selected.includes('rank') || !selected){
//... the column selection stuff from above
// *************
//only bother with adjusting the sort if rank was used as a column.
// if no sort exists & rank is added as a column, use rank as default sort as opposed to ID or created_at
if(!filters.$sort){
params.sequelize.order = [Sequelize.literal('rank DESC')];
}else{
// if there is a $sort present, then convert the rank column to sequelize literal. This avoids an issue where ORDER by is expecting "books"."rank" instead of just "rank"
const order = utils.getOrder(filters.$sort);
params.sequelize.order = order.map((col:string)=>{
if (col[0] == 'rank'){
return [Sequelize.literal(`rank ${col[1]}`)];
}
return col;
});
}
// *************
}
Вы видите, что логика для параметра order
sequelize очень похожа на логику для attributes
. Но вместо массива строк, который использует attributes
, order
- это массив массивов, таких как [ [ [ 'rank', 'DESC' ], ['title', 'ASC' ]
. И мы хотим использовать порядок только в том случае, если существует колонка rank, иначе будет выдана ошибка.
Теперь, когда код запущен, нажмите http://localhost:3030/books?published[$gt]=2016&$search=%21Johnson%26Kennedy%26%28space%7Chistory%29&$select[0]=id&$select[1]=title&$select[2]=rank&$sort[rank][]=1&$sort[title][]=-1
.
И вы должны увидеть:
{
"total": 2,
"limit": 10,
"skip": 0,
"data": [
{
"id": 2,
"title": "A Concise History of the Netherlands",
"rank": 0.409156
},
{
"id": 3,
"title": "Exploring Kennedy Space Center (Travel America's Landmarks)",
"rank": 0.997993
}
]
}
Теперь у нас есть функционирующий хук, с помощью которого мы можем искать, сортировать, выбирать по колонке search_vector
!
Поздравляем!
Если у вас есть вопросы или исправления, пожалуйста, комментируйте ниже. Код для этого руководства доступен по адресу https://github.com/jamesvillarrubia/feathers-postgresql-search.