Commit | Line | Data |
---|---|---|
04c06e85 | 1 | <?php |
a9229942 | 2 | |
04c06e85 | 3 | namespace wcf\system\tagging; |
a9229942 | 4 | |
04c06e85 MW |
5 | use wcf\data\object\type\ObjectTypeCache; |
6 | use wcf\data\tag\Tag; | |
7 | use wcf\data\tag\TagAction; | |
1bcacda8 | 8 | use wcf\data\tag\TagList; |
04c06e85 | 9 | use wcf\system\database\util\PreparedStatementConditionBuilder; |
79bbb75a | 10 | use wcf\system\exception\InvalidObjectTypeException; |
95939e32 | 11 | use wcf\system\language\LanguageFactory; |
04c06e85 MW |
12 | use wcf\system\SingletonFactory; |
13 | use wcf\system\WCF; | |
32775cf9 | 14 | use wcf\util\ArrayUtil; |
04c06e85 MW |
15 | |
16 | /** | |
17 | * Manages the tagging of objects. | |
a9229942 TD |
18 | * |
19 | * @author Marcel Werk | |
20 | * @copyright 2001-2019 WoltLab GmbH | |
21 | * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php> | |
22 | * @package WoltLabSuite\Core\System\Tagging | |
04c06e85 | 23 | */ |
a9229942 TD |
24 | class TagEngine extends SingletonFactory |
25 | { | |
26 | /** | |
27 | * Adds tags to a tagged object. | |
28 | * | |
29 | * @param string $objectType | |
30 | * @param int $objectID | |
31 | * @param array $tags | |
32 | * @param int $languageID | |
33 | * @param bool $replace | |
34 | */ | |
35 | public function addObjectTags($objectType, $objectID, array $tags, $languageID, $replace = true) | |
36 | { | |
37 | $objectTypeID = $this->getObjectTypeID($objectType); | |
38 | $tags = \array_unique(\array_reduce(ArrayUtil::trim(\array_map(static function ($tag) { | |
39 | return \explode(',', $tag); | |
40 | }, $tags)), 'array_merge', [])); | |
41 | ||
42 | // remove tags prior to apply the new ones (prevents duplicate entries) | |
43 | if ($replace) { | |
683280d5 TD |
44 | $sql = "DELETE tag_to_object |
45 | FROM wcf" . WCF_N . "_tag_to_object tag_to_object | |
46 | INNER JOIN wcf" . WCF_N . "_tag tag | |
47 | ON tag.tagID = tag_to_object.tagID | |
48 | WHERE tag_to_object.objectTypeID = ? | |
49 | AND tag_to_object.objectID = ? | |
50 | AND tag.languageID = ?"; | |
a9229942 TD |
51 | $statement = WCF::getDB()->prepareStatement($sql); |
52 | $statement->execute([ | |
53 | $objectTypeID, | |
54 | $objectID, | |
55 | $languageID, | |
56 | ]); | |
57 | } | |
58 | ||
59 | // get tag ids | |
60 | $tagIDs = []; | |
61 | foreach ($tags as $tag) { | |
62 | if (empty($tag)) { | |
63 | continue; | |
64 | } | |
65 | ||
66 | // enforce max length | |
67 | if (\mb_strlen($tag) > TAGGING_MAX_TAG_LENGTH) { | |
68 | $tag = \mb_substr($tag, 0, TAGGING_MAX_TAG_LENGTH); | |
69 | } | |
70 | ||
71 | // find existing tag | |
72 | $tagObj = Tag::getTag($tag, $languageID); | |
73 | if ($tagObj === null) { | |
74 | // create new tag | |
75 | $tagAction = new TagAction([], 'create', [ | |
76 | 'data' => [ | |
77 | 'name' => $tag, | |
78 | 'languageID' => $languageID, | |
79 | ], | |
80 | ]); | |
81 | ||
82 | $tagAction->executeAction(); | |
83 | $returnValues = $tagAction->getReturnValues(); | |
84 | $tagObj = $returnValues['returnValues']; | |
85 | } | |
86 | ||
87 | if ($tagObj->synonymFor !== null) { | |
88 | $tagIDs[$tagObj->synonymFor] = $tagObj->synonymFor; | |
89 | } else { | |
90 | $tagIDs[$tagObj->tagID] = $tagObj->tagID; | |
91 | } | |
92 | } | |
93 | ||
94 | // save tags | |
95 | $sql = "INSERT INTO wcf" . WCF_N . "_tag_to_object | |
96 | (objectID, tagID, objectTypeID, languageID) | |
97 | VALUES (?, ?, ?, ?)"; | |
98 | WCF::getDB()->beginTransaction(); | |
99 | $statement = WCF::getDB()->prepareStatement($sql); | |
100 | foreach ($tagIDs as $tagID) { | |
101 | $statement->execute([$objectID, $tagID, $objectTypeID, $languageID]); | |
102 | } | |
103 | WCF::getDB()->commitTransaction(); | |
104 | } | |
105 | ||
106 | /** | |
107 | * Deletes all tags assigned to given tagged object. | |
108 | * | |
109 | * @param string $objectType | |
110 | * @param int $objectID | |
111 | * @param int $languageID | |
112 | */ | |
113 | public function deleteObjectTags($objectType, $objectID, $languageID = null) | |
114 | { | |
115 | $objectTypeID = $this->getObjectTypeID($objectType); | |
116 | ||
683280d5 TD |
117 | $sql = "DELETE tag_to_object |
118 | FROM wcf" . WCF_N . "_tag_to_object tag_to_object | |
119 | INNER JOIN wcf" . WCF_N . "_tag tag | |
120 | ON tag.tagID = tag_to_object.tagID | |
121 | WHERE tag_to_object.objectTypeID = ? | |
122 | AND tag_to_object.objectID = ? | |
123 | " . ($languageID !== null ? "AND tag.languageID = ?" : ""); | |
a9229942 TD |
124 | $statement = WCF::getDB()->prepareStatement($sql); |
125 | $parameters = [ | |
126 | $objectTypeID, | |
127 | $objectID, | |
128 | ]; | |
129 | if ($languageID !== null) { | |
130 | $parameters[] = $languageID; | |
131 | } | |
132 | $statement->execute($parameters); | |
133 | } | |
134 | ||
135 | /** | |
136 | * Deletes all tags assigned to given tagged objects. | |
137 | * | |
138 | * @param string $objectType | |
139 | * @param int[] $objectIDs | |
140 | */ | |
141 | public function deleteObjects($objectType, array $objectIDs) | |
142 | { | |
143 | $objectTypeID = $this->getObjectTypeID($objectType); | |
144 | ||
145 | $conditionsBuilder = new PreparedStatementConditionBuilder(); | |
146 | $conditionsBuilder->add('objectTypeID = ?', [$objectTypeID]); | |
147 | $conditionsBuilder->add('objectID IN (?)', [$objectIDs]); | |
148 | ||
149 | $sql = "DELETE FROM wcf" . WCF_N . "_tag_to_object | |
150 | " . $conditionsBuilder; | |
151 | $statement = WCF::getDB()->prepareStatement($sql); | |
152 | $statement->execute($conditionsBuilder->getParameters()); | |
153 | } | |
154 | ||
155 | /** | |
156 | * Returns all tags set for given object. | |
157 | * | |
158 | * @param string $objectType | |
159 | * @param int $objectID | |
160 | * @param int[] $languageIDs | |
161 | * @return Tag[] | |
162 | */ | |
163 | public function getObjectTags($objectType, $objectID, array $languageIDs = []) | |
164 | { | |
165 | $tags = $this->getObjectsTags($objectType, [$objectID], $languageIDs); | |
166 | ||
167 | return $tags[$objectID] ?? []; | |
168 | } | |
169 | ||
170 | /** | |
171 | * Returns all tags set for given objects. | |
172 | * | |
173 | * @param string $objectType | |
174 | * @param int[] $objectIDs | |
175 | * @param int[] $languageIDs | |
176 | * @return array | |
177 | */ | |
178 | public function getObjectsTags($objectType, array $objectIDs, array $languageIDs = []) | |
179 | { | |
180 | $objectTypeID = $this->getObjectTypeID($objectType); | |
181 | ||
182 | $conditions = new PreparedStatementConditionBuilder(); | |
183 | $conditions->add("tag_to_object.objectTypeID = ?", [$objectTypeID]); | |
184 | $conditions->add("tag_to_object.objectID IN (?)", [$objectIDs]); | |
185 | if (!empty($languageIDs)) { | |
186 | foreach ($languageIDs as $index => $languageID) { | |
187 | if (!$languageID) { | |
188 | unset($languageIDs[$index]); | |
189 | } | |
190 | } | |
191 | ||
192 | // The `languageID` is part of the index, skipping it will cause MySQL to skip the | |
193 | // `objectID` column, causing a partial table scan. | |
194 | if (empty($languageIDs)) { | |
195 | // The `languageID` column is never null, tags are always assigned to a language | |
196 | // thus we cannot use the content language ids here. | |
197 | foreach (LanguageFactory::getInstance()->getLanguages() as $language) { | |
198 | $languageIDs[] = $language->languageID; | |
199 | } | |
200 | } | |
201 | ||
e7a6cb1a | 202 | $conditions->add("tag.languageID IN (?)", [$languageIDs]); |
a9229942 TD |
203 | } |
204 | ||
205 | $sql = "SELECT tag.*, tag_to_object.objectID | |
206 | FROM wcf" . WCF_N . "_tag_to_object tag_to_object | |
207 | LEFT JOIN wcf" . WCF_N . "_tag tag | |
c240c98a | 208 | ON tag.tagID = tag_to_object.tagID |
a9229942 TD |
209 | " . $conditions; |
210 | $statement = WCF::getDB()->prepareStatement($sql); | |
211 | $statement->execute($conditions->getParameters()); | |
212 | ||
213 | $tags = []; | |
214 | while ($tag = $statement->fetchObject(Tag::class)) { | |
215 | /** @noinspection PhpUndefinedFieldInspection */ | |
216 | $objectID = $tag->objectID; | |
217 | if (!isset($tags[$objectID])) { | |
218 | $tags[$objectID] = []; | |
219 | } | |
220 | $tags[$objectID][$tag->tagID] = $tag; | |
221 | } | |
222 | ||
223 | return $tags; | |
224 | } | |
225 | ||
226 | /** | |
227 | * Returns id of the object type with the given name. | |
228 | * | |
229 | * @param string $objectType | |
230 | * @return int | |
231 | * @throws InvalidObjectTypeException | |
232 | */ | |
233 | public function getObjectTypeID($objectType) | |
234 | { | |
235 | // get object type | |
236 | $objectTypeObj = ObjectTypeCache::getInstance() | |
237 | ->getObjectTypeByName('com.woltlab.wcf.tagging.taggableObject', $objectType); | |
238 | if ($objectTypeObj === null) { | |
239 | throw new InvalidObjectTypeException($objectType, 'com.woltlab.wcf.tagging.taggableObject'); | |
240 | } | |
241 | ||
242 | return $objectTypeObj->objectTypeID; | |
243 | } | |
244 | ||
245 | /** | |
246 | * Returns the implicit language id based on the language id of existing tags. | |
247 | * | |
248 | * NULL indicates that there are no tags, otherwise the language id with the most | |
249 | * associated tags for that object is returned, but can still be arbitrary if | |
250 | * there are two or more top language ids with the same amount of tags. | |
251 | * | |
252 | * @param string $objectType | |
253 | * @param int $objectID | |
254 | * @return int|null | |
255 | */ | |
256 | public function getImplicitLanguageID($objectType, $objectID) | |
257 | { | |
258 | $existingTags = $this->getObjectTags($objectType, $objectID); | |
259 | if (empty($existingTags)) { | |
c0b28aa2 | 260 | return null; |
a9229942 TD |
261 | } |
262 | ||
263 | $languageIDs = []; | |
264 | foreach ($existingTags as $tag) { | |
265 | if (!isset($languageIDs[$tag->languageID])) { | |
266 | $languageIDs[$tag->languageID] = 0; | |
267 | } | |
268 | $languageIDs[$tag->languageID]++; | |
269 | } | |
270 | ||
271 | \arsort($languageIDs, \SORT_NUMERIC); | |
272 | ||
273 | return \key($languageIDs); | |
274 | } | |
275 | ||
276 | /** | |
277 | * @param Tag[] $tags | |
278 | * @return int[] | |
279 | */ | |
280 | public function getTagIDs($tags) | |
281 | { | |
282 | return \array_map(static function ($tag) { | |
283 | return $tag->tagID; | |
284 | }, $tags); | |
285 | } | |
286 | ||
287 | /** | |
288 | * Generates the inner SQL statement to fetch object ids that have all listed | |
289 | * tags assigned to them. | |
290 | * | |
291 | * @param string $objectType | |
292 | * @param Tag[] $tags | |
293 | * @return array | |
294 | * @since 5.2 | |
295 | */ | |
296 | public function getSubselectForObjectsByTags($objectType, array $tags) | |
297 | { | |
298 | $parameters = [$this->getObjectTypeID($objectType)]; | |
299 | $tagIDs = \implode(',', \array_map(static function (Tag $tag) use (&$parameters) { | |
300 | $parameters[] = $tag->tagID; | |
301 | ||
302 | return '?'; | |
303 | }, $tags)); | |
304 | $parameters[] = \count($tags); | |
305 | ||
306 | $sql = "SELECT objectID | |
307 | FROM wcf" . WCF_N . "_tag_to_object | |
308 | WHERE objectTypeID = ? | |
309 | AND tagID IN (" . $tagIDs . ") | |
310 | GROUP BY objectID | |
311 | HAVING COUNT(objectID) = ?"; | |
312 | ||
313 | return [ | |
314 | 'sql' => $sql, | |
315 | 'parameters' => $parameters, | |
316 | ]; | |
317 | } | |
318 | ||
319 | /** | |
320 | * Returns the matching tags by name. | |
321 | * | |
322 | * @param string[] $names | |
323 | * @param int $languageID | |
324 | * @return Tag[] | |
325 | * @since 5.2 | |
326 | */ | |
327 | public function getTagsByName(array $names, $languageID) | |
328 | { | |
329 | $tagList = new TagList(); | |
330 | $tagList->getConditionBuilder()->add('name IN (?)', [$names]); | |
331 | $tagList->getConditionBuilder()->add('languageID = ?', [$languageID ?: WCF::getLanguage()->languageID]); | |
332 | $tagList->readObjects(); | |
333 | ||
334 | return $tagList->getObjects(); | |
335 | } | |
04c06e85 | 336 | } |