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     * @return array<string, string>
94     */
95    private function titleMap(string $class, string $uid, array $content, int &$dynamic): array
96    {
97        $descriptor = $this->resolver->descriptor($class);
98
99        return match ($descriptor['kind']) {
100            Resolver::KIND_FIELD => $this->resolver->fieldMap($content, $descriptor['field']),
101            Resolver::KIND_DYNAMIC => $this->dynamicMap($class, $uid, $content, $dynamic),
102            default => [],
103        };
104    }
105
106    /**
107     * @param class-string $class
108     *
109     * @return array<string, string>
110     */
111    private function dynamicMap(string $class, string $uid, array $content, int &$dynamic): array
112    {
113        $node = $this->factory->create($class, $this->context, $this->cms, [
114            'uid' => $uid,
115            'content' => $content,
116        ]);
117
118        $provider = $this->resolver->provider($node);
119
120        if ($provider === null) {
121            return [];
122        }
123
124        $dynamic++;
125
126        return $this->resolver->dynamicMap(
127            fn(Locale $locale): string => $this->context->withLocale(
128                $locale,
129                $provider->title(...),
130            ),
131            $this->locales,
132        );
133    }
134
135    /**
136     * @return array<string, class-string>
137     */
138    private function nodeClasses(): array
139    {
140        $map = [];
141        $tag = $this->context->container->tag(Bootstrap::NODE_TAG);
142
143        foreach ($tag->entries() as $handle) {
144            $class = $tag->entry($handle)->definition();
145
146            if (is_string($class) && class_exists($class)) {
147                $map[$handle] = $class;
148            }
149        }
150
151        return $map;
152    }
153
154    private function toggleTriggers(string $action): void
155    {
156        foreach (self::TRIGGERS as $trigger) {
157            $sql = "ALTER TABLE /*:cms.prefix:*/nodes {$action} TRIGGER /*:cms.obj:*/{$trigger}";
158            $sql = $this->conn->config->placeholders?->compileSql($sql, __FILE__) ?? $sql;
159
160            $this->db->execute($sql)->run();
161        }
162    }
163}