-
-
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 a42ec7e
Showing
12 changed files
with
1,171 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,29 @@ | ||
<?php | ||
|
||
namespace App\migrations; | ||
|
||
class Migration202410040001AddIndexLinksUrl | ||
{ | ||
public function migrate(): bool | ||
{ | ||
$database = \Minz\Database::get(); | ||
|
||
$database->exec(<<<'SQL' | ||
CREATE EXTENSION IF NOT EXISTS pg_trgm; | ||
CREATE INDEX idx_links_url ON links USING gin (url gin_trgm_ops); | ||
SQL); | ||
|
||
return true; | ||
} | ||
|
||
public function rollback(): bool | ||
{ | ||
$database = \Minz\Database::get(); | ||
|
||
$database->exec(<<<'SQL' | ||
DROP INDEX idx_links_url; | ||
SQL); | ||
|
||
return 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
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 ILIKE {$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); | ||
} | ||
} |
Oops, something went wrong.