167 lines
5.9 KiB
PHP
167 lines
5.9 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Repository;
|
|
|
|
use App\Entity\ContentDocument;
|
|
use App\Enum\ContentFetchState;
|
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
|
use Doctrine\DBAL\ArrayParameterType;
|
|
use Doctrine\DBAL\ParameterType;
|
|
use Doctrine\Persistence\ManagerRegistry;
|
|
|
|
/**
|
|
* @extends ServiceEntityRepository<ContentDocument>
|
|
*/
|
|
class ContentDocumentRepository extends ServiceEntityRepository
|
|
{
|
|
public function __construct(ManagerRegistry $registry)
|
|
{
|
|
parent::__construct($registry, ContentDocument::class);
|
|
}
|
|
|
|
/**
|
|
* @return array<string, ContentDocument> keyed by ContentDocument::getKey()
|
|
*/
|
|
public function findAllByKey(): array
|
|
{
|
|
$documents = [];
|
|
|
|
foreach ($this->findAll() as $document) {
|
|
$documents[$document->getKey()] = $document;
|
|
}
|
|
|
|
return $documents;
|
|
}
|
|
|
|
/**
|
|
* Full-text search over the indexed documents that can be linked to.
|
|
*
|
|
* DBAL rather than DQL: Doctrine has no MATCH ... AGAINST. The score is weighted by type
|
|
* so that, at similar relevance, what people book ranks above what they read.
|
|
*
|
|
* @param string $booleanQuery a MySQL boolean-mode query, built by ContentSearchQuery
|
|
* @param list<string> $types empty for all
|
|
* @param array<string, float> $typeWeights
|
|
*
|
|
* @return list<array{type: string, uid: int, title: string, url: string, site: ?string, breadcrumb: ?string, body_md: string, score: float}>
|
|
*/
|
|
public function searchFulltext(string $booleanQuery, array $types, ?string $site, array $typeWeights, int $limit): array
|
|
{
|
|
$weight = 'CASE type';
|
|
$params = ['query' => $booleanQuery, 'limit' => $limit];
|
|
$paramTypes = ['limit' => ParameterType::INTEGER];
|
|
|
|
foreach (array_keys($typeWeights) as $i => $type) {
|
|
$weight .= sprintf(' WHEN :wt%d THEN :wv%d', $i, $i);
|
|
$params['wt'.$i] = $type;
|
|
$params['wv'.$i] = $typeWeights[$type];
|
|
}
|
|
$weight .= ' ELSE 1 END';
|
|
|
|
$sql = 'SELECT type, uid, title, url, site, breadcrumb, body_md,'
|
|
.' MATCH(title, breadcrumb, body_md) AGAINST(:query IN BOOLEAN MODE) * '.$weight.' AS score'
|
|
.' FROM content_document'
|
|
.' WHERE indexed = 1 AND url IS NOT NULL'
|
|
.' AND MATCH(title, breadcrumb, body_md) AGAINST(:query IN BOOLEAN MODE)';
|
|
|
|
if ([] !== $types) {
|
|
$sql .= ' AND type IN (:types)';
|
|
$params['types'] = $types;
|
|
$paramTypes['types'] = ArrayParameterType::STRING;
|
|
}
|
|
|
|
if (null !== $site) {
|
|
$sql .= ' AND site = :site';
|
|
$params['site'] = $site;
|
|
}
|
|
|
|
$sql .= ' ORDER BY score DESC, uid ASC LIMIT :limit';
|
|
|
|
$rows = $this->getEntityManager()->getConnection()->fetchAllAssociative($sql, $params, $paramTypes);
|
|
|
|
return array_map(static fn (array $row): array => [
|
|
'type' => (string) $row['type'],
|
|
'uid' => (int) $row['uid'],
|
|
'title' => (string) $row['title'],
|
|
'url' => (string) $row['url'],
|
|
'site' => null === $row['site'] ? null : (string) $row['site'],
|
|
'breadcrumb' => null === $row['breadcrumb'] ? null : (string) $row['breadcrumb'],
|
|
'body_md' => (string) $row['body_md'],
|
|
'score' => (float) $row['score'],
|
|
], $rows);
|
|
}
|
|
|
|
/**
|
|
* The `(type, uid)` of every fetched record whose text matches, for resolving free text
|
|
* in the trip search. Unlike searchFulltext(), records without a URL count: a hotel with
|
|
* no page of its own still leads to the products it belongs to.
|
|
*
|
|
* @param list<string> $types
|
|
*
|
|
* @return list<array{type: string, uid: int}>
|
|
*/
|
|
public function matchRecords(string $booleanQuery, array $types, string $language): array
|
|
{
|
|
$rows = $this->getEntityManager()->getConnection()->fetchAllAssociative(
|
|
'SELECT type, uid FROM content_document'
|
|
.' WHERE fetch_state = :ok AND language = :language AND type IN (:types)'
|
|
.' AND MATCH(title, breadcrumb, body_md) AGAINST(:query IN BOOLEAN MODE)',
|
|
['ok' => ContentFetchState::Ok->value, 'language' => $language, 'types' => $types, 'query' => $booleanQuery],
|
|
['types' => ArrayParameterType::STRING],
|
|
);
|
|
|
|
return array_map(static fn (array $row): array => ['type' => (string) $row['type'], 'uid' => (int) $row['uid']], $rows);
|
|
}
|
|
|
|
/**
|
|
* Fetched records of the given types in one language, keyed by `type:uid`.
|
|
*
|
|
* @param list<string> $types
|
|
*
|
|
* @return array<string, ContentDocument>
|
|
*/
|
|
public function findRecords(array $types, string $language): array
|
|
{
|
|
/** @var list<ContentDocument> $documents */
|
|
$documents = $this->createQueryBuilder('d')
|
|
->where('d.type IN (:types)')
|
|
->andWhere('d.language = :language')
|
|
->andWhere('d.fetchState = :ok')
|
|
->setParameter('types', $types)
|
|
->setParameter('language', $language)
|
|
->setParameter('ok', ContentFetchState::Ok)
|
|
->getQuery()
|
|
->getResult();
|
|
|
|
$indexed = [];
|
|
|
|
foreach ($documents as $document) {
|
|
$indexed[$document->getRecordRef()] = $document;
|
|
}
|
|
|
|
return $indexed;
|
|
}
|
|
|
|
/**
|
|
* The indexed document published at a URL. Trailing slashes are not significant.
|
|
*/
|
|
public function findOneIndexedByUrl(string $url): ?ContentDocument
|
|
{
|
|
$trimmed = rtrim($url, '/');
|
|
|
|
/** @var list<ContentDocument> $documents */
|
|
$documents = $this->createQueryBuilder('d')
|
|
->where('d.url IN (:urls)')
|
|
->andWhere('d.indexed = true')
|
|
->setParameter('urls', [$trimmed, $trimmed.'/'])
|
|
->orderBy('d.id', 'ASC')
|
|
->setMaxResults(1)
|
|
->getQuery()
|
|
->getResult();
|
|
|
|
return $documents[0] ?? null;
|
|
}
|
|
}
|