-
-
Notifications
You must be signed in to change notification settings - Fork 4
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
new: Allow to search for tags and URL parts
- Loading branch information
1 parent
4cec5ba
commit 89d3ce0
Showing
10 changed files
with
1,140 additions
and
17 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,190 @@ | ||
<?php | ||
|
||
namespace App\search_engine; | ||
|
||
use App\models; | ||
use Minz\Database; | ||
|
||
/** | ||
* @author Marien Fressinaud <dev@marienfressinaud.fr> | ||
* @license http://www.gnu.org/licenses/agpl-3.0.en.html AGPL | ||
*/ | ||
class LinksSearcher | ||
{ | ||
/** | ||
* @param array{ | ||
* 'offset'?: int, | ||
* 'limit'?: int|'ALL', | ||
* } $pagination | ||
* | ||
* @return models\Link[] | ||
*/ | ||
public static function getLinks( | ||
models\User $user, | ||
Query $query, | ||
array $pagination = [], | ||
): array { | ||
$default_pagination = [ | ||
'offset' => 0, | ||
'limit' => 'ALL', | ||
]; | ||
|
||
$pagination = array_merge($default_pagination, $pagination); | ||
|
||
$parameters = [ | ||
':query' => '', | ||
':user_id' => $user->id, | ||
':offset' => $pagination['offset'], | ||
]; | ||
|
||
$limit_statement = ''; | ||
if ($pagination['limit'] !== 'ALL') { | ||
$limit_statement = 'LIMIT :limit'; | ||
$parameters[':limit'] = $pagination['limit']; | ||
} | ||
|
||
list($query_statement, $query_parameters) = self::buildWhereQuery($query); | ||
$parameters = array_merge($parameters, $query_parameters); | ||
|
||
$sql = <<<SQL | ||
SELECT | ||
l.*, | ||
l.created_at AS published_at, | ||
( | ||
SELECT COUNT(*) FROM messages m | ||
WHERE m.link_id = l.id | ||
) AS number_comments | ||
FROM links l, plainto_tsquery('french', :query) AS query | ||
WHERE l.user_id = :user_id | ||
{$query_statement} | ||
-- Exclude the links that are ONLY in the "never" collection | ||
AND NOT EXISTS ( | ||
SELECT 1 | ||
FROM links_to_collections lc, collections c | ||
WHERE lc.link_id = l.id | ||
AND lc.collection_id = c.id | ||
HAVING COUNT(CASE WHEN c.type='never' THEN 1 END) = 1 | ||
AND COUNT(c.*) = 1 | ||
) | ||
ORDER BY published_at DESC, l.id | ||
OFFSET :offset | ||
{$limit_statement} | ||
SQL; | ||
|
||
$database = Database::get(); | ||
$statement = $database->prepare($sql); | ||
$statement->execute($parameters); | ||
|
||
return models\Link::fromDatabaseRows($statement->fetchAll()); | ||
} | ||
|
||
public static function countLinks(models\User $user, Query $query): int | ||
{ | ||
$parameters = [ | ||
':query' => '', | ||
':user_id' => $user->id, | ||
]; | ||
|
||
list($query_statement, $query_parameters) = self::buildWhereQuery($query); | ||
$parameters = array_merge($parameters, $query_parameters); | ||
|
||
$sql = <<<SQL | ||
SELECT COUNT(l.id) | ||
FROM links l, plainto_tsquery('french', :query) AS query | ||
WHERE l.user_id = :user_id | ||
{$query_statement} | ||
-- Exclude the links that are ONLY in the "never" collection | ||
AND NOT EXISTS ( | ||
SELECT 1 | ||
FROM links_to_collections lc, collections c | ||
WHERE lc.link_id = l.id | ||
AND lc.collection_id = c.id | ||
HAVING COUNT(CASE WHEN c.type='never' THEN 1 END) = 1 | ||
AND COUNT(c.*) = 1 | ||
) | ||
SQL; | ||
|
||
$database = Database::get(); | ||
$statement = $database->prepare($sql); | ||
$statement->execute($parameters); | ||
|
||
return intval($statement->fetchColumn()); | ||
} | ||
|
||
/** | ||
* @return array{string, array<string, mixed>} | ||
*/ | ||
private static function buildWhereQuery(Query $query): array | ||
{ | ||
$where_sql = ''; | ||
$parameters = []; | ||
|
||
$textConditions = $query->getConditions('text'); | ||
$textValues = array_map(function (Query\Condition $condition): string { | ||
return $condition->getValue(); | ||
}, $textConditions); | ||
$textQuery = implode(' ', $textValues); | ||
|
||
if ($textQuery !== '') { | ||
$where_sql .= ' AND search_index @@ query'; | ||
$parameters['query'] = $textQuery; | ||
} | ||
|
||
$qualifierConditions = $query->getConditions('qualifier'); | ||
|
||
foreach ($qualifierConditions as $condition) { | ||
$qualifier = $condition->getQualifier(); | ||
if ($qualifier === 'url') { | ||
$value = $condition->getValue(); | ||
|
||
$parameter_name = ':url' . (count($parameters) + 1); | ||
|
||
$where_sql .= " AND l.url LIKE {$parameter_name}"; | ||
|
||
$parameters[$parameter_name] = "%{$value}%"; | ||
} | ||
} | ||
|
||
$tagConditions = $query->getConditions('tag'); | ||
|
||
$tags_parameters = []; | ||
$not_tags_parameters = []; | ||
|
||
foreach ($tagConditions as $condition) { | ||
$parameter_name = ':tag' . (count($parameters) + 1); | ||
|
||
$value = $condition->getValue(); | ||
|
||
$parameters[$parameter_name] = $value; | ||
|
||
if ($condition->not()) { | ||
$not_tags_parameters[] = $parameter_name; | ||
} else { | ||
$tags_parameters[] = $parameter_name; | ||
} | ||
} | ||
|
||
if ($tags_parameters) { | ||
$tags_statement = implode(',', $tags_parameters); | ||
$where_sql .= " AND l.tags::jsonb ??& array[{$tags_statement}]"; | ||
} | ||
|
||
if ($not_tags_parameters) { | ||
$not_tags_statement = implode(',', $not_tags_parameters); | ||
$where_sql .= " AND NOT (l.tags::jsonb ??| array[{$not_tags_statement}])"; | ||
} | ||
|
||
return [$where_sql, $parameters]; | ||
} | ||
} |
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,48 @@ | ||
<?php | ||
|
||
namespace App\search_engine; | ||
|
||
/** | ||
* @author Marien Fressinaud <dev@marienfressinaud.fr> | ||
* @license http://www.gnu.org/licenses/agpl-3.0.en.html AGPL | ||
*/ | ||
class Query | ||
{ | ||
/** @var Query\Condition[] */ | ||
private array $conditions = []; | ||
|
||
public function addCondition(Query\Condition $condition): void | ||
{ | ||
$this->conditions[] = $condition; | ||
} | ||
|
||
/** | ||
* @param 'text'|'qualifier'|'tag'|'any' $type | ||
* | ||
* @return Query\Condition[] | ||
*/ | ||
public function getConditions(string $type = 'any'): array | ||
{ | ||
if ($type === 'any') { | ||
return $this->conditions; | ||
} | ||
|
||
return array_filter($this->conditions, function ($condition) use ($type) { | ||
if ($type === 'text') { | ||
return $condition->isTextCondition(); | ||
} elseif ($type === 'qualifier') { | ||
return $condition->isQualifierCondition(); | ||
} elseif ($type === 'tag') { | ||
return $condition->isTagCondition(); | ||
} | ||
}); | ||
} | ||
|
||
public static function fromString(string $queryString): Query | ||
{ | ||
$tokenizer = new Query\Tokenizer(); | ||
$parser = new Query\Parser(); | ||
$tokens = $tokenizer->tokenize($queryString); | ||
return $parser->parse($tokens); | ||
} | ||
} |
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,70 @@ | ||
<?php | ||
|
||
namespace App\search_engine\Query; | ||
|
||
/** | ||
* @author Marien Fressinaud <dev@marienfressinaud.fr> | ||
* @author Probesys <https://github.com/Probesys/bileto> | ||
* @license http://www.gnu.org/licenses/agpl-3.0.en.html AGPL | ||
*/ | ||
class Condition | ||
{ | ||
public const TYPES = ['text', 'qualifier', 'tag']; | ||
|
||
/** | ||
* @param value-of<self::TYPES> $type | ||
*/ | ||
private function __construct( | ||
/** @var value-of<self::TYPES> */ | ||
private string $type, | ||
private string $value, | ||
private ?string $qualifier, | ||
private bool $not, | ||
) { | ||
} | ||
|
||
public static function textCondition(string $value): self | ||
{ | ||
return new self('text', $value, null, false); | ||
} | ||
|
||
public static function qualifierCondition(string $qualifier, string $value): self | ||
{ | ||
return new self('qualifier', $value, $qualifier, false); | ||
} | ||
|
||
public static function tagCondition(string $value, bool $not): self | ||
{ | ||
return new self('tag', $value, null, $not); | ||
} | ||
|
||
public function isTextCondition(): bool | ||
{ | ||
return $this->type === 'text'; | ||
} | ||
|
||
public function isQualifierCondition(): bool | ||
{ | ||
return $this->type === 'qualifier'; | ||
} | ||
|
||
public function isTagCondition(): bool | ||
{ | ||
return $this->type === 'tag'; | ||
} | ||
|
||
public function getValue(): string | ||
{ | ||
return $this->value; | ||
} | ||
|
||
public function getQualifier(): string | ||
{ | ||
return $this->qualifier ?? ''; | ||
} | ||
|
||
public function not(): bool | ||
{ | ||
return $this->not; | ||
} | ||
} |
Oops, something went wrong.