Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.45% covered (success)
95.45%
21 / 22
85.71% covered (warning)
85.71%
6 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
Sort
95.45% covered (success)
95.45%
21 / 22
85.71% covered (warning)
85.71%
6 / 7
12
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
 order
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 chooseCollation
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 expression
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 collation
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 indexName
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 valid
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\Field\Field;
10use Cosray\Locale;
11
12/** Shared title expressions and collation selection for queries and indexes. */
13final class Sort
14{
15    /** @var array<string, string>|null */
16    private ?array $collations = null;
17
18    public function __construct(
19        private readonly Database $db,
20    ) {}
21
22    public function order(Locale $locale, string $column = 'title'): string
23    {
24        $this->collations ??= array_column($this->db->titleSort->collations()->all(), 'identifier', 'name');
25        $collation = self::chooseCollation($locale->id, $this->collations);
26        $expression = self::expression($locale, $column);
27
28        return $collation === null ? $expression : "({$expression}) COLLATE {$collation}";
29    }
30
31    /** @param array<string, string> $available Catalog names mapped to quoted SQL identifiers. */
32    public static function chooseCollation(string $localeId, array $available): ?string
33    {
34        return $available[self::collation($localeId)] ?? $available['und-x-icu'] ?? null;
35    }
36
37    /** Match Resolver::stored(), including locale fallback and whitespace-only titles. */
38    public static function expression(Locale|string $locale, string $column = 'title'): string
39    {
40        $ids = $locale instanceof Locale ? [$locale->id, ...$locale->fallbacks()] : [$locale];
41        $ids[] = Field::NEUTRAL_LOCALE;
42        $values = [];
43
44        foreach (array_unique($ids) as $id) {
45            if (!self::valid($id)) {
46                throw new RuntimeException("Invalid title sort locale '{$id}'");
47            }
48
49            $value = "{$column}->>'{$id}'";
50            $values[] =
51                "CASE WHEN jsonb_typeof({$column}->'{$id}') = 'string' "
52                . "AND BTRIM({$value}, E' \\t\\n\\r\\013') <> '' THEN {$value} END";
53        }
54
55        return 'COALESCE(' . implode(', ', $values) . ')';
56    }
57
58    public static function collation(string $localeId): string
59    {
60        return str_replace('_', '-', $localeId) . '-x-icu';
61    }
62
63    public static function indexName(string $localeId): string
64    {
65        if (!self::valid($localeId)) {
66            throw new RuntimeException("Invalid title sort locale '{$localeId}'");
67        }
68
69        return 'ix_nodes_title_' . str_replace('-', '_', $localeId);
70    }
71
72    public static function valid(string $localeId): bool
73    {
74        return preg_match('/^[A-Za-z][A-Za-z0-9_-]*$/D', $localeId) === 1;
75    }
76}