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