Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.44% covered (success)
94.44%
51 / 54
83.33% covered (warning)
83.33%
5 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
EntityReindexer
94.44% covered (success)
94.44%
51 / 54
83.33% covered (warning)
83.33%
5 / 6
9.01
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getIndexClass
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 updateIndex
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 recalculateIdfForAll
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 createUpdateQuery
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
2
 recalculateIdf
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2namespace Apie\DoctrineEntityDatalayer;
3
4use Apie\Core\Context\ApieContext;
5use Apie\Core\Entities\EntityInterface;
6use Apie\Core\Indexing\Indexer;
7use Apie\StorageMetadata\Interfaces\StorageDtoInterface;
8use Apie\StorageMetadataBuilder\Interfaces\HasIndexInterface;
9use Doctrine\DBAL\ArrayParameterType;
10use Doctrine\DBAL\Platforms\SqlitePlatform;
11use ReflectionClass;
12
13final class EntityReindexer
14{
15    public function __construct(private readonly OrmBuilder $ormBuilder, private readonly Indexer $indexer)
16    {
17    }
18
19    /**
20     * @param ReflectionClass<covariant HasIndexInterface> $doctrineEntity
21     * @return class-string<covariant StorageDtoInterface>
22     */
23    private function getIndexClass(ReflectionClass $doctrineEntity): string
24    {
25        return $doctrineEntity->getMethod('getIndexTable')->invoke(null)->name;
26    }
27
28    /**
29     * Should be called after storing a doctrine entity from a domain entity. It recalculates the search terms
30     * for the entity. For searching we use TF IDF and recalculate the TF of the entity. The IDF needs to be
31     * recalculated in a separate function with an update query.
32     *
33     * @see https://en.wikipedia.org/wiki/Tf%E2%80%93idf
34     */
35    public function updateIndex(
36        HasIndexInterface $doctrineEntity,
37        EntityInterface $entity,
38        bool $skipIdf = false
39    ): void {
40        $entityManager = $this->ormBuilder->createEntityManager();
41        $newIndexes = $this->indexer->getIndexesForObject(
42            $entity,
43            new ApieContext()
44        );
45        $doctrineEntity->replaceIndexes($newIndexes);
46        $termsToUpdate = array_keys($newIndexes);
47        $entityManager->persist($doctrineEntity);
48        $entityManager->flush();
49        if (!$skipIdf) {
50            $this->recalculateIdf($doctrineEntity, $termsToUpdate);
51        }
52    }
53
54    /**
55     * @param ReflectionClass<covariant HasIndexInterface> $doctrineEntity
56     */
57    public function recalculateIdfForAll(ReflectionClass $doctrineEntity): void
58    {
59        $query = $this->createUpdateQuery($doctrineEntity);
60        $entityManager = $this->ormBuilder->createEntityManager();
61        $entityManager->getConnection()->executeQuery($query);
62    }
63
64    /**
65     * @param ReflectionClass<covariant HasIndexInterface> $doctrineEntity
66     */
67    private function createUpdateQuery(ReflectionClass $doctrineEntity): string
68    {
69        $entityManager = $this->ormBuilder->createEntityManager();
70        $tableName = (new ReflectionClass($this->getIndexClass($doctrineEntity)))->getShortName();
71        $columnName = 'ref_' . $doctrineEntity->getShortName() . '_id';
72        $totalDocumentQuery = sprintf(
73            '(SELECT total_documents FROM (SELECT COUNT(DISTINCT %s) AS total_documents FROM %s WHERE %s IS NOT NULL) AS sub1)',
74            $columnName,
75            $tableName,
76            $columnName
77        );
78        $documentWithTermQuery = sprintf(
79            'SELECT documents_with_term FROM (SELECT text, COUNT(DISTINCT %s) AS documents_with_term FROM %s WHERE %s IS NOT NULL GROUP BY text) AS sub WHERE sub.text',
80            $columnName,
81            $tableName,
82            $columnName
83        );
84        $connection = $entityManager->getConnection();
85        $query = sprintf(
86            'UPDATE %s AS t
87            SET idf = COALESCE(%s((%s)/(%s = t.text LIMIT 1)), 1)
88            WHERE %s IS NOT NULL AND EXISTS (SELECT 1 FROM (SELECT text, COUNT(DISTINCT %s) AS documents_with_term FROM %s GROUP BY text) AS sub WHERE sub.text = t.text LIMIT 1);',
89            $tableName,
90            // @phpstan-ignore class.notFound
91            $connection->getDatabasePlatform() instanceof SqlitePlatform ? '' : 'log',
92            $totalDocumentQuery,
93            $documentWithTermQuery,
94            $columnName,
95            $columnName,
96            $tableName
97        );
98
99        return $query;
100    }
101
102    /**
103     * @param array<int, string> $termsToUpdate
104     */
105    private function recalculateIdf(HasIndexInterface $doctrineEntity, array $termsToUpdate): void
106    {
107        if (empty($termsToUpdate)) {
108            return;
109        }
110        $query = $this->createUpdateQuery(new ReflectionClass($doctrineEntity));
111        $query = preg_replace('#LIMIT 1\);$#', 'AND t.text IN (:terms) LIMIT 1);', $query);
112        $entityManager = $this->ormBuilder->createEntityManager();
113        $entityManager->getConnection()->executeQuery(
114            $query,
115            ['terms' => array_values($termsToUpdate)],
116            ['terms' => ArrayParameterType::STRING]
117        );
118    }
119}