Commit 7d6c3cc9 authored by Emma's avatar Emma 🦉

full-text search

parent 74b4b364
Pipeline #53319043 passed with stage
in 1 minute and 35 seconds
search:
controller: App\Controller\SearchController::search
path: /search
methods: [GET]
external_search:
controller: App\Controller\SearchController::external
path: /external_search
methods: [POST]
\ No newline at end of file
methods: [POST]
......@@ -13,6 +13,7 @@ doctrine:
url: '%env(resolve:DATABASE_URL)%'
types:
inet: App\DBAL\Type\InetType
tsvector: App\DBAL\Type\TsvectorType
orm:
auto_generate_proxy_classes: '%kernel.debug%'
naming_strategy: doctrine.orm.naming_strategy.underscore
......
......@@ -2,6 +2,7 @@
namespace App\Controller;
use App\Repository\SearchRepository;
use Symfony\Component\HttpFoundation\Request;
final class SearchController extends AbstractController {
......@@ -14,6 +15,19 @@ final class SearchController extends AbstractController {
$this->enableExternalSearch = $enableExternalSearch;
}
public function search(Request $request, SearchRepository $search) {
$searchOptions = $search->parseRequest($request);
if ($searchOptions) {
$results = $search->search($searchOptions);
}
return $this->render('search/results.html.twig', [
'query' => $searchOptions['query'] ?? null,
'results' => $results ?? [],
]);
}
public function external(Request $request) {
if (!$this->enableExternalSearch) {
throw $this->createNotFoundException('Search is not enabled');
......
<?php
namespace App\DBAL\Type;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Types\Type;
final class TsvectorType extends Type {
public function getSQLDeclaration(array $fieldDeclaration, AbstractPlatform $platform) {
return 'TSVECTOR';
}
public function getName() {
return 'tsvector';
}
public function convertToDatabaseValueSQL($sqlExpr, AbstractPlatform $platform): string {
return sprintf('to_tsvector(%s)', $sqlExpr);
}
}
......@@ -12,7 +12,9 @@ use Symfony\Component\Serializer\Annotation\SerializedName;
/**
* @ORM\Entity(repositoryClass="App\Repository\CommentRepository")
* @ORM\Table(name="comments")
* @ORM\Table(name="comments", indexes={
* @ORM\Index(name="comments_search_idx", columns={"search_doc"})
* })
*/
class Comment extends Votable {
/**
......@@ -141,6 +143,11 @@ class Comment extends Votable {
*/
private $mentions;
/**
* @ORM\Column(type="tsvector", nullable=true)
*/
private $searchDoc;
/**
* @Groups({"comment:read"})
*/
......
......@@ -13,7 +13,8 @@ use Symfony\Component\Serializer\Annotation\SerializedName;
/**
* @ORM\Entity(repositoryClass="App\Repository\SubmissionRepository")
* @ORM\Table(name="submissions", indexes={
* @ORM\Index(name="submissions_ranking_id_idx", columns={"ranking", "id"})
* @ORM\Index(name="submissions_ranking_id_idx", columns={"ranking", "id"}),
* @ORM\Index(name="submissions_search_idx", columns={"search_doc"}),
* })
*/
class Submission extends Votable {
......@@ -49,7 +50,7 @@ class Submission extends Votable {
*
* @Groups({"submission:read"})
*
* @var string
* @var string|null
*/
private $url;
......@@ -58,7 +59,7 @@ class Submission extends Votable {
*
* @Groups({"submission:read"})
*
* @var string
* @var string|null
*/
private $body;
......@@ -178,6 +179,13 @@ class Submission extends Votable {
*/
private $locked = false;
/**
* @ORM\Column(type="tsvector", nullable=true)
*
* @var string
*/
private $searchDoc;
/**
* @Groups({"submission:read"})
*/
......
<?php
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20190322210445 extends AbstractMigration {
public function getDescription(): string {
return 'Add search document column for submissions and comments';
}
public function up(Schema $schema): void {
$this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'postgresql', 'Migration can only be executed safely on \'postgresql\'.');
$this->addSql('ALTER TABLE submissions ADD search_doc TSVECTOR');
$this->addSql('COMMENT ON COLUMN submissions.search_doc IS \'(DC2Type:tsvector)\'');
$this->addSql('ALTER TABLE comments ADD search_doc TSVECTOR');
$this->addSql('COMMENT ON COLUMN comments.search_doc IS \'(DC2Type:tsvector)\'');
$this->addSql(<<<'EOSQL'
CREATE FUNCTION submissions_search_trigger() RETURNS trigger AS $$
begin
new.search_doc :=
setweight(to_tsvector(new.title), 'A') ||
setweight(to_tsvector(COALESCE(new.url, '')), 'B') ||
setweight(to_tsvector(COALESCE(new.body, '')), 'D');
return new;
end
$$ LANGUAGE plpgsql
EOSQL
);
$this->addSql('CREATE TRIGGER submissions_search_update BEFORE INSERT OR UPDATE ON submissions FOR EACH ROW EXECUTE PROCEDURE submissions_search_trigger()');
$this->addSql('CREATE TRIGGER comments_search_update BEFORE INSERT OR UPDATE ON comments FOR EACH ROW EXECUTE PROCEDURE tsvector_update_trigger(search_doc, \'pg_catalog.english\', body)');
$this->addSql('CREATE INDEX submissions_search_idx ON submissions USING GIN (search_doc)');
$this->addSql('CREATE INDEX comments_search_idx ON comments USING GIN (search_doc)');
$this->addSql('UPDATE submissions SET id = id');
$this->addSql('UPDATE comments SET id = id');
}
public function down(Schema $schema): void {
$this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'postgresql', 'Migration can only be executed safely on \'postgresql\'.');
$this->addSql('DROP FUNCTION submissions_search_trigger CASCADE');
$this->addSql('ALTER TABLE submissions DROP search_doc');
$this->addSql('DROP TRIGGER comments_search_update ON comments');
$this->addSql('ALTER TABLE comments DROP search_doc');
}
}
<?php
namespace App\Repository;
use App\Entity\Comment;
use App\Entity\Submission;
use Doctrine\DBAL\Types\Type;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Query\ResultSetMappingBuilder;
use Symfony\Component\HttpFoundation\Request;
final class SearchRepository {
private const MAX_PER_PAGE = 50;
private const ENTITY_TYPES = [
Comment::class => 'comment',
Submission::class => 'submission',
];
/**
* @var EntityManagerInterface
*/
private $em;
public function __construct(EntityManagerInterface $em) {
$this->em = $em;
}
/**
* The more amazing-er cross-entity search engine.
*
* @param array $options An array with the following options:
* - `query` (string)
* - `is` (array with entity class names)
*
* @return array
*
* @todo pagination, more options!
*/
public function search(array $options): array {
$results = [];
foreach ($options['is'] as $entityClass) {
foreach ($this->getResultsForEntity($options, $entityClass) as $row) {
$results[] = $row;
}
}
\usort($results, function ($a, $b) {
return $b['search_rank'] <=> $a['search_rank'];
});
return \array_slice($results, 0, self::MAX_PER_PAGE);
}
public static function parseRequest(Request $request): ?array {
$query = $request->query->get('q');
if (!\is_string($query)) {
return null;
}
$options = [
'is' => [Comment::class, Submission::class],
'query' => $query,
];
return $options;
}
private function getResultsForEntity(array $options, string $entityClass): iterable {
if (!isset(self::ENTITY_TYPES[$entityClass])) {
throw new \InvalidArgumentException(sprintf(
'non-searchable entity "%s"',
$entityClass
));
}
$rsm = new ResultSetMappingBuilder($this->em);
$rsm->addRootEntityFromClassMetadata($entityClass, 'e');
$rsm->addScalarResult('entity', 'entity');
$rsm->addScalarResult('search_rank', 'search_rank');
$table = $this->em->getClassMetadata($entityClass)->getTableName();
$qb = $this->em->getConnection()->createQueryBuilder()
->select($rsm->generateSelectClause())
->addSelect(":entity AS entity")
->addSelect("ts_rank(search_doc, plainto_tsquery(:query)) AS search_rank")
->from($table, 'e')
->where('search_doc @@ plainto_tsquery(:query)')
->setParameter('entity', self::ENTITY_TYPES[$entityClass])
->setParameter('query', $options['query'])
->orderBy('search_rank', 'DESC')
->setMaxResults(self::MAX_PER_PAGE);
return $this->em->createNativeQuery($qb->getSQL(), $rsm)
->setParameters($qb->getParameters())
->execute();
}
}
{% extends 'base.html.twig' %}
{% from 'submission/_macros.html.twig' import submission %}
{% from 'comment/_macros.html.twig' import comment %}
{% block title 'heading.search'|trans %}
{% block body %}
<h1 class="page-heading">{{ block('title') }}</h1>
<form action="{{ path('search') }}" method="GET" class="form">
<div class="form-row form-row--single-line form__row">
<label for="query" class="form-row__align text-align-right">{{ 'label.search_query'|trans }}</label>
<input name="q" type="search" value="{{ query }}" id="query" class="form-control">
</div>
<div class="form-row form-row--single-line form__row form__button-row">
<span class="form-row__align" role="presentation"></span>
<button class="button">{{ 'action.search'|trans }}</button>
</div>
</form>
{% if query is not empty %}
<h2>{{ 'heading.search_results'|trans({
'%query%': '<em>%s</em>'|format(query|e),
'%count%': results|length
})|raw }}</h2>
{% endif %}
{% for result in results %}
{% if result.entity == 'comment' %}
{{ comment(result[0], { show_context: true }) }}
{% elseif result.entity == 'submission' %}
{{ submission(result[0], { show_body: true }) }}
{% endif %}
{% else %}
{% if query is not empty %}
<p><small class="dimmed">{{ 'flash.no_entries_to_display'|trans }}</small></p>
{% endif %}
{% endfor %}
{% endblock %}
......@@ -197,6 +197,8 @@ heading:
category: 'Category: %category%'
hide_this_forum: Hide this forum
you_were_mentioned: You were mentioned by %user%
search: Search
search_results: '{0} No results for %query%|{1} 1 result for %query%:|]1,Inf[ %count% results for %query%:'
help:
delete_forum_warning: All content on this forum will be irreversibly deleted!
......@@ -316,6 +318,7 @@ label:
let_forums_override_preferred_theme: Let forums override preferred theme
search: Search for a post...
show_thumbnails: Show thumbnails
search_query: Search query
log:
comment_deletion: '%user% deleted comment by %author% in "%submission%"'
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment