Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.18% covered (success)
97.18%
69 / 71
85.71% covered (success)
85.71%
6 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
GlossaryRepository
97.18% covered (success)
97.18%
69 / 71
85.71% covered (success)
85.71%
6 / 7
15
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
 fetchAll
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
3
 fetch
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
3
 create
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
2
 update
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 prepareItem
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
2.26
 delete
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3/**
4 * The glossary repository class.
5 *
6 * This Source Code Form is subject to the terms of the Mozilla Public License,
7 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
8 * obtain one at https://mozilla.org/MPL/2.0/.
9 *
10 * @package   phpMyFAQ
11 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
12 * @copyright 2025 phpMyFAQ Team
13 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
14 * @link      https://www.phpmyfaq.de
15 * @since     2025-11-05
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Glossary;
21
22use phpMyFAQ\Configuration;
23use phpMyFAQ\Database;
24use phpMyFAQ\Strings;
25use Psr\Log\LoggerInterface;
26use Psr\Log\NullLogger;
27
28readonly class GlossaryRepository implements GlossaryRepositoryInterface
29{
30    /** Maximum number of characters the item column can hold. */
31    private const int ITEM_MAX_LENGTH = 254;
32
33    public function __construct(
34        private Configuration $configuration,
35        private LoggerInterface $logger = new NullLogger(),
36    ) {
37    }
38
39    public function fetchAll(string $language): array
40    {
41        $databaseDriver = $this->configuration->getDb();
42        $items = [];
43
44        $sql = "SELECT id, lang, item, definition FROM %sfaqglossary WHERE lang = '%s' ORDER BY item ASC";
45        $query = sprintf($sql, Database::getTablePrefix(), $databaseDriver->escape($language));
46
47        $result = $databaseDriver->query($query);
48        if ($result === false) {
49            $this->logger->error(message: 'Glossary fetchAll query failed', context: ['language' => $language]);
50            return [];
51        }
52
53        $row = $databaseDriver->fetchObject($result);
54        while ($row) {
55            $items[] = [
56                'id' => (int) $row->id,
57                'language' => (string) $row->lang,
58                'item' => stripslashes((string) $row->item),
59                'definition' => stripslashes((string) $row->definition),
60            ];
61            $row = $databaseDriver->fetchObject($result);
62        }
63
64        return $items;
65    }
66
67    /**
68     * @return array{id:int, language:string, item:string, definition:string}|array{}
69     */
70    public function fetch(int $id, string $language): array
71    {
72        $databaseDriver = $this->configuration->getDb();
73
74        $sql = "SELECT id, lang, item, definition FROM %sfaqglossary WHERE id = %d AND lang = '%s'";
75        $query = sprintf($sql, Database::getTablePrefix(), $id, $databaseDriver->escape($language));
76
77        $result = $databaseDriver->query($query);
78        if ($result === false) {
79            $this->logger->warning(message: 'Glossary fetch failed', context: ['id' => $id, 'language' => $language]);
80            return [];
81        }
82
83        $row = $databaseDriver->fetchObject($result);
84        if (!is_object($row)) {
85            return [];
86        }
87
88        return [
89            'id' => (int) $row->id,
90            'language' => (string) $row->lang,
91            'item' => stripslashes((string) $row->item),
92            'definition' => stripslashes((string) $row->definition),
93        ];
94    }
95
96    public function create(string $language, string $item, string $definition): bool
97    {
98        $databaseDriver = $this->configuration->getDb();
99
100        $escapedItem = $databaseDriver->escape($this->prepareItem($item));
101        $escapedDefinition = $databaseDriver->escape(Strings::htmlspecialchars($definition));
102        $escapedLanguage = $databaseDriver->escape($language);
103
104        $id = $databaseDriver->nextId(Database::getTablePrefix() . 'faqglossary', column: 'id');
105        $sql = "INSERT INTO %sfaqglossary (id, lang, item, definition) VALUES (%d, '%s', '%s', '%s')";
106        $query = sprintf($sql, Database::getTablePrefix(), $id, $escapedLanguage, $escapedItem, $escapedDefinition);
107
108        $ok = (bool) $databaseDriver->query($query);
109        if (!$ok) {
110            $this->logger->error(message: 'Glossary create failed', context: [
111                'language' => $language,
112                'item' => $item,
113            ]);
114        }
115
116        return $ok;
117    }
118
119    public function update(int $id, string $language, string $item, string $definition): bool
120    {
121        $databaseDriver = $this->configuration->getDb();
122
123        $escapedItem = $databaseDriver->escape($this->prepareItem($item));
124        $escapedDefinition = $databaseDriver->escape(Strings::htmlspecialchars($definition));
125        $escapedLanguage = $databaseDriver->escape($language);
126
127        $sql = "UPDATE %sfaqglossary SET item = '%s', definition = '%s' WHERE id = %d AND lang = '%s'";
128        $query = sprintf($sql, Database::getTablePrefix(), $escapedItem, $escapedDefinition, $id, $escapedLanguage);
129
130        $ok = (bool) $databaseDriver->query($query);
131        if (!$ok) {
132            $this->logger->error(message: 'Glossary update failed', context: ['id' => $id, 'language' => $language]);
133        }
134
135        return $ok;
136    }
137
138    /**
139     * Encodes and length-limits an item before it is escaped for the query.
140     *
141     * Truncating must happen before escaping: cutting an escaped string can strip a quote while
142     * keeping the escape character in front of it, which breaks out of the SQL string literal.
143     */
144    private function prepareItem(string $item): string
145    {
146        $encoded = Strings::htmlspecialchars(Strings::substr($item, 0, self::ITEM_MAX_LENGTH));
147
148        if (Strings::strlen($encoded) > self::ITEM_MAX_LENGTH) {
149            $encoded = Strings::substr($encoded, 0, self::ITEM_MAX_LENGTH);
150            // Drop an entity the cut may have left half-written
151            $encoded = (string) preg_replace('/&[#0-9a-zA-Z]*$/', replacement: '', subject: $encoded);
152        }
153
154        return $encoded;
155    }
156
157    public function delete(int $id, string $language): bool
158    {
159        $databaseDriver = $this->configuration->getDb();
160
161        $sql = "DELETE FROM %sfaqglossary WHERE id = %d AND lang = '%s'";
162        $query = sprintf($sql, Database::getTablePrefix(), $id, $databaseDriver->escape($language));
163
164        $ok = (bool) $databaseDriver->query($query);
165        if (!$ok) {
166            $this->logger->warning(message: 'Glossary delete failed', context: ['id' => $id, 'language' => $language]);
167        }
168
169        return $ok;
170    }
171}