Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.23% covered (success)
96.23%
51 / 53
71.43% covered (warning)
71.43%
5 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
Rebuild
96.23% covered (success)
96.23%
51 / 53
71.43% covered (warning)
71.43%
5 / 7
19
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 run
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 rebuild
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
5.01
 titleMap
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 dynamicMap
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
2.00
 nodeClasses
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 toggleTriggers
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Title;
6
7use Celema\Quma\Connection;
8use Celema\Quma\Database;
9use Cosray\Bootstrap;
10use Cosray\Cms;
11use Cosray\Context;
12use Cosray\Locale;
13use Cosray\Locales;
14use Cosray\Node\Factory;
15use Cosray\Node\Types;
16
17/**
18 * Re-materializes `nodes.title` for every live node from its stored content —
19 * the backfill after the column is added and the recovery tool after imports.
20 * Runs in a booted app context because resolving a title needs the node type
21 * registry (title field / Contract\Title). The change and history triggers are
22 * suspended so a rebuild is not recorded as a wave of edits.
23 */
24class Rebuild
25{
26    private const array TRIGGERS = ['nodes_trigger_02_change', 'nodes_trigger_03_history'];
27
28    private readonly Database $db;
29    private readonly Factory $factory;
30    private readonly Resolver $resolver;
31
32    public function __construct(
33        private readonly Context $context,
34        private readonly Cms $cms,
35        private readonly Locales $locales,
36        private readonly Connection $conn,
37        Types $types,
38    ) {
39        $this->db = $context->db;
40        $this->factory = $cms->nodeFactory();
41        $this->resolver = new Resolver($types);
42    }
43
44    /**
45     * @return array{nodes: int, dynamic: int, empty: int}
46     */
47    public function run(): array
48    {
49        $classes = $this->nodeClasses();
50
51        $this->toggleTriggers('DISABLE');
52
53        try {
54            return $this->rebuild($classes);
55        } finally {
56            $this->toggleTriggers('ENABLE');
57        }
58    }
59
60    /**
61     * @param array<string, class-string> $classes
62     *
63     * @return array{nodes: int, dynamic: int, empty: int}
64     */
65    private function rebuild(array $classes): array
66    {
67        $nodes = 0;
68        $dynamic = 0;
69        $empty = 0;
70
71        foreach ($this->db->nodes->allForTitle()->lazy() as $row) {
72            $nodes++;
73            $uid = (string) $row['uid'];
74            $class = $classes[(string) $row['type_handle']] ?? null;
75            $decoded = json_decode((string) $row['content'], true);
76            $content = is_array($decoded) ? $decoded : [];
77
78            $map = $class === null ? [] : $this->titleMap($class, $uid, $content, $dynamic);
79
80            if ($map === []) {
81                $empty++;
82            }
83
84            $this->db->nodes->updateTitle(['uid' => $uid, 'title' => json_encode($map)])->run();
85        }
86
87        return ['nodes' => $nodes, 'dynamic' => $dynamic, 'empty' => $empty];
88    }
89
90    /**
91     * @param class-string $class
92     *
93     * @param array<string, mixed> $content
94     * @return array<string, string>
95     */
96    private function titleMap(string $class, string $uid, array $content, int &$dynamic): array
97    {
98        $descriptor = $this->resolver->descriptor($class);
99
100        return match ($descriptor['kind']) {
101            Resolver::KIND_FIELD => $this->resolver->fieldMap($content, $descriptor['field']),
102            Resolver::KIND_DYNAMIC => $this->dynamicMap($class, $uid, $content, $dynamic),
103            default => [],
104        };
105    }
106
107    /**
108     * @param class-string $class
109     *
110     * @param array<string, mixed> $content
111     * @return array<string, string>
112     */
113    private function dynamicMap(string $class, string $uid, array $content, int &$dynamic): array
114    {
115        $node = $this->factory->create($class, $this->context, $this->cms, [
116            'uid' => $uid,
117            'content' => $content,
118        ]);
119
120        $provider = $this->resolver->provider($node);
121
122        if ($provider === null) {
123            return [];
124        }
125
126        $dynamic++;
127
128        return $this->resolver->dynamicMap(
129            fn(Locale $locale): string => $this->context->withLocale(
130                $locale,
131                $provider->title(...),
132            ),
133            $this->locales,
134        );
135    }
136
137    /**
138     * @return array<string, class-string>
139     */
140    private function nodeClasses(): array
141    {
142        $map = [];
143        $tag = $this->context->container->tag(Bootstrap::NODE_TAG);
144
145        foreach ($tag->entries() as $handle) {
146            $class = $tag->entry($handle)->definition();
147
148            if (is_string($class) && class_exists($class)) {
149                $map[$handle] = $class;
150            }
151        }
152
153        return $map;
154    }
155
156    private function toggleTriggers(string $action): void
157    {
158        foreach (self::TRIGGERS as $trigger) {
159            $sql = "ALTER TABLE /*:cms.prefix:*/nodes {$action} TRIGGER /*:cms.obj:*/{$trigger}";
160            $sql = $this->conn->config->placeholders?->compileSql($sql, __FILE__) ?? $sql;
161
162            $this->db->execute($sql)->run();
163        }
164    }
165}