Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.14% covered (success)
97.14%
34 / 35
80.00% covered (warning)
80.00%
4 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
Indexes
97.14% covered (success)
97.14%
34 / 35
80.00% covered (warning)
80.00%
4 / 5
14
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
 reconcile
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 update
96.77% covered (success)
96.77%
30 / 31
0.00% covered (danger)
0.00%
0 / 1
10
 existing
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 signature
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Title;
6
7use Celema\Quma\Database;
8use Cosray\Exception\RuntimeException;
9use Cosray\Locales;
10use Cosray\Util\Transaction;
11
12/** Reconciles only the title indexes on the configured nodes table. */
13final class Indexes
14{
15    public function __construct(
16        private readonly Database $db,
17        private readonly Locales $locales,
18    ) {}
19
20    /** @return array{created: int, dropped: int, locales: list<string>} */
21    public function reconcile(): array
22    {
23        return Transaction::run($this->db, $this->update(...));
24    }
25
26    /** @return array{created: int, dropped: int, locales: list<string>} */
27    private function update(): array
28    {
29        $sort = new Sort($this->db);
30        $wanted = [];
31        foreach ($this->locales as $locale) {
32            $name = Sort::indexName($locale->id);
33            if (strlen($name) > 63 || isset($wanted[$name])) {
34                throw new RuntimeException("Title sort index name is too long or ambiguous: '{$name}'");
35            }
36            $wanted[$name] = ['locale' => $locale->id, 'expression' => $sort->order($locale)];
37        }
38
39        $existing = $this->existing();
40        $created = 0;
41        $dropped = 0;
42        foreach ($wanted as $name => $definition) {
43            $source = hash('sha256', $definition['expression']);
44            $current = $existing[$name] ?? null;
45            if (
46                $current !== null
47                && $current['valid']
48                && $current['signature'] === $this->signature($source, $current['definition'])
49            ) {
50                continue;
51            }
52            if ($current !== null) {
53                $this->db->titleSort->drop(['index' => $current['identifier']])->run();
54                $dropped++;
55            }
56            $this->db->titleSort->create(['index' => $name, 'expression' => $definition['expression']])->run();
57            $current = $this->existing()[$name];
58            $this->db->titleSort->mark([
59                'index' => $current['identifier'],
60                // COMMENT cannot bind a parameter. Both hashes are generated here.
61                'signature' => $this->db->quote($this->signature($source, $current['definition'])),
62            ])->run();
63            $created++;
64        }
65
66        foreach (array_diff_key($existing, $wanted) as $index) {
67            $this->db->titleSort->drop(['index' => $index['identifier']])->run();
68            $dropped++;
69        }
70
71        return ['created' => $created, 'dropped' => $dropped, 'locales' => array_column($wanted, 'locale')];
72    }
73
74    /** @return array<array<mixed>> */
75    private function existing(): array
76    {
77        return array_column($this->db->titleSort->indexes()->all(), null, 'name');
78    }
79
80    private function signature(string $source, string $definition): string
81    {
82        // Include PostgreSQL's actual definition so manual replacements are
83        // detected without attempting to normalize its expression deparser.
84        return 'cosray-title-sort:' . $source . ':' . hash('sha256', $definition);
85    }
86}