Lines 97.18% 69 / 71
Methods 85.71% 6 / 7
Classes 0.00% 0 / 1
Covered by tests of size
Name Lines Methods CRAP
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 fetchAll 100.00% 18 / 18 100.00% 1 / 1 3
 fetch 100.00% 16 / 16 100.00% 1 / 1 3
 create 100.00% 14 / 14 100.00% 1 / 1 2
 update 100.00% 10 / 10 100.00% 1 / 1 2
 prepareItem 60.00% 3 / 5 0.00% 0 / 1 2.26
 delete 100.00% 7 / 7 100.00% 1 / 1 2
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}