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