Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
5.23% |
38 / 727 |
|
7.55% |
4 / 53 |
CRAP | |
0.00% |
0 / 1 |
CargoUtils | |
5.23% |
38 / 727 |
|
7.55% |
4 / 53 |
65120.42 | |
0.00% |
0 / 1 |
getDB | |
0.00% |
0 / 40 |
|
0.00% |
0 / 1 |
272 | |||
getMainDBForRead | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
getMainDBForWrite | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
getPageProp | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
6 | |||
getAllPageProps | |
0.00% |
0 / 17 |
|
0.00% |
0 / 1 |
12 | |||
getTemplateIDForDBTable | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
6 | |||
formatError | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
displayErrorMessage | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getTables | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
12 | |||
getParentTables | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
20 | |||
getChildTables | |
0.00% |
0 / 19 |
|
0.00% |
0 / 1 |
30 | |||
getDrilldownTabsParams | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
20 | |||
getTableSchemas | |
0.00% |
0 / 22 |
|
0.00% |
0 / 1 |
90 | |||
getTableNameForTemplate | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
6 | |||
makeDifferentAlias | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
smartSplit | |
69.44% |
25 / 36 |
|
0.00% |
0 / 1 |
21.42 | |||
findQuotedStringEnd | |
0.00% |
0 / 15 |
|
0.00% |
0 / 1 |
72 | |||
removeQuotedStrings | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
4 | |||
removeNamespaceFromFileName | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
getSQLFieldPattern | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
getSQLTableAndFieldPattern | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
getSQLTablePattern | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
isSQLStringLiteral | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
6 | |||
getDateFunctions | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
6 | |||
smartParse | |
0.00% |
0 / 19 |
|
0.00% |
0 / 1 |
156 | |||
parsePageForStorage | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
20 | |||
recreateDBTablesForTemplate | |
0.00% |
0 / 44 |
|
0.00% |
0 / 1 |
272 | |||
tableFullyExists | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
6 | |||
fieldTypeToSQLType | |
0.00% |
0 / 50 |
|
0.00% |
0 / 1 |
1482 | |||
createCargoTableOrTables | |
0.00% |
0 / 75 |
|
0.00% |
0 / 1 |
420 | |||
createTable | |
0.00% |
0 / 61 |
|
0.00% |
0 / 1 |
306 | |||
specialTableNames | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
6 | |||
fullTextMatchSQL | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
coordinatePartToNumber | |
0.00% |
0 / 28 |
|
0.00% |
0 / 1 |
110 | |||
parseCoordinatesString | |
0.00% |
0 / 30 |
|
0.00% |
0 / 1 |
110 | |||
escapedFieldName | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
6 | |||
joinOfMainAndFieldTable | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
2 | |||
joinOfMainAndParentTable | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
2 | |||
joinOfFieldAndMainTable | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
6 | |||
joinOfSingleFieldAndHierarchyTable | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
2 | |||
escapedInsert | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
6 | |||
makeLink | |
33.33% |
2 / 6 |
|
0.00% |
0 / 1 |
12.41 | |||
getSpecialPage | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
getContentLang | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
logTableAction | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
6 | |||
validateHierarchyStructure | |
0.00% |
0 / 20 |
|
0.00% |
0 / 1 |
56 | |||
validateFieldDescriptionString | |
0.00% |
0 / 17 |
|
0.00% |
0 / 1 |
90 | |||
getTemplateLinksTo | |
0.00% |
0 / 26 |
|
0.00% |
0 / 1 |
30 | |||
setParserOutputPageProperty | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
globalFields | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
2 | |||
addGlobalFieldsToSchema | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
12 | |||
stringContainsParentheses | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
6 | |||
makeWikiPage | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 |
1 | <?php |
2 | /** |
3 | * Utility functions for the Cargo extension. |
4 | * |
5 | * @author Yaron Koren |
6 | * @ingroup Cargo |
7 | */ |
8 | |
9 | use MediaWiki\Linker\LinkRenderer; |
10 | use MediaWiki\Linker\LinkTarget; |
11 | use MediaWiki\MediaWikiServices; |
12 | |
13 | class CargoUtils { |
14 | |
15 | private static $CargoDB = null; |
16 | |
17 | public static function getDB() { |
18 | if ( self::$CargoDB != null && self::$CargoDB->isOpen() ) { |
19 | return self::$CargoDB; |
20 | } |
21 | |
22 | global $wgDBuser, $wgDBpassword, $wgDBprefix, $wgDBservers; |
23 | global $wgCargoDBserver, $wgCargoDBname, $wgCargoDBuser, $wgCargoDBpassword, $wgCargoDBprefix, $wgCargoDBtype; |
24 | |
25 | $services = MediaWikiServices::getInstance(); |
26 | $lb = $services->getDBLoadBalancer(); |
27 | $dbr = $lb->getConnectionRef( DB_REPLICA ); |
28 | $server = $dbr->getServer(); |
29 | $name = $dbr->getDBname(); |
30 | $type = $dbr->getType(); |
31 | |
32 | // We need $wgCargoDBtype for other functions. |
33 | if ( $wgCargoDBtype === null ) { |
34 | $wgCargoDBtype = $type; |
35 | } |
36 | $dbServer = $wgCargoDBserver === null ? $server : $wgCargoDBserver; |
37 | $dbName = $wgCargoDBname === null ? $name : $wgCargoDBname; |
38 | |
39 | // Server (host), db name, and db type can be retrieved from $dbr via |
40 | // public methods, but username and password cannot. If these values are |
41 | // not set for Cargo, get them from either $wgDBservers or wgDBuser and |
42 | // $wgDBpassword, depending on whether or not there are multiple DB servers. |
43 | if ( $wgCargoDBuser !== null ) { |
44 | $dbUsername = $wgCargoDBuser; |
45 | } elseif ( is_array( $wgDBservers ) && isset( $wgDBservers[0] ) ) { |
46 | $dbUsername = $wgDBservers[0]['user']; |
47 | } else { |
48 | $dbUsername = $wgDBuser; |
49 | } |
50 | if ( $wgCargoDBpassword !== null ) { |
51 | $dbPassword = $wgCargoDBpassword; |
52 | } elseif ( is_array( $wgDBservers ) && isset( $wgDBservers[0] ) ) { |
53 | $dbPassword = $wgDBservers[0]['password']; |
54 | } else { |
55 | $dbPassword = $wgDBpassword; |
56 | } |
57 | |
58 | if ( $wgCargoDBprefix !== null ) { |
59 | $dbTablePrefix = $wgCargoDBprefix; |
60 | } else { |
61 | $dbTablePrefix = $wgDBprefix . 'cargo__'; |
62 | } |
63 | |
64 | $params = [ |
65 | 'host' => $dbServer, |
66 | 'user' => $dbUsername, |
67 | 'password' => $dbPassword, |
68 | 'dbname' => $dbName, |
69 | 'tablePrefix' => $dbTablePrefix, |
70 | ]; |
71 | |
72 | if ( $type === 'sqlite' ) { |
73 | $params['dbFilePath'] = $dbr->getDbFilePath(); |
74 | } elseif ( $type === 'postgres' ) { |
75 | global $wgDBport; |
76 | // @TODO - a $wgCargoDBport variable is still needed. |
77 | $params['port'] = $wgDBport; |
78 | } |
79 | |
80 | if ( method_exists( $services, 'getDatabaseFactory' ) ) { |
81 | // MW 1.39+ |
82 | self::$CargoDB = $services->getDatabaseFactory()->create( $wgCargoDBtype, $params ); |
83 | } else { |
84 | self::$CargoDB = Database::factory( $wgCargoDBtype, $params ); |
85 | } |
86 | return self::$CargoDB; |
87 | } |
88 | |
89 | /** |
90 | * Provides a reference to the main (not the Cargo) database for read |
91 | * access. |
92 | * |
93 | * @return \Wikimedia\Rdbms\IDatabase|false |
94 | */ |
95 | public static function getMainDBForRead() { |
96 | $lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory(); |
97 | if ( method_exists( $lbFactory, 'getReplicaDatabase' ) ) { |
98 | // MW 1.40+ |
99 | // The correct type \Wikimedia\Rdbms\IReadableDatabase cannot be used |
100 | // as return type, as that class only exists since 1.40. |
101 | // @phan-suppress-next-line PhanTypeMismatchReturnSuperType |
102 | return $lbFactory->getReplicaDatabase(); |
103 | } else { |
104 | return $lbFactory->getMainLB()->getConnection( DB_REPLICA ); |
105 | } |
106 | } |
107 | |
108 | /** |
109 | * Provides a reference to the main (not the Cargo) database for write |
110 | * access. |
111 | * |
112 | * @return \Wikimedia\Rdbms\IDatabase|false |
113 | */ |
114 | public static function getMainDBForWrite() { |
115 | $lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory(); |
116 | if ( method_exists( $lbFactory, 'getPrimaryDatabase' ) ) { |
117 | // MW 1.40+ |
118 | return $lbFactory->getPrimaryDatabase(); |
119 | } else { |
120 | return $lbFactory->getMainLB()->getConnection( DB_PRIMARY ); |
121 | } |
122 | } |
123 | |
124 | /** |
125 | * Gets a page property for the specified page ID and property name. |
126 | */ |
127 | public static function getPageProp( $pageID, $pageProp ) { |
128 | $lb = MediaWikiServices::getInstance()->getDBLoadBalancer(); |
129 | $dbr = $lb->getConnectionRef( DB_REPLICA ); |
130 | $value = $dbr->selectField( 'page_props', [ |
131 | 'pp_value' |
132 | ], [ |
133 | 'pp_page' => $pageID, |
134 | 'pp_propname' => $pageProp, |
135 | ] |
136 | ); |
137 | |
138 | if ( !$value ) { |
139 | return null; |
140 | } |
141 | |
142 | return $value; |
143 | } |
144 | |
145 | /** |
146 | * Similar to getPageProp(). |
147 | */ |
148 | public static function getAllPageProps( $pageProp ) { |
149 | $lb = MediaWikiServices::getInstance()->getDBLoadBalancer(); |
150 | $dbr = $lb->getConnectionRef( DB_REPLICA ); |
151 | $res = $dbr->select( 'page_props', [ |
152 | 'pp_page', |
153 | 'pp_value' |
154 | ], [ |
155 | 'pp_propname' => $pageProp |
156 | ] |
157 | ); |
158 | |
159 | $pagesPerValue = []; |
160 | foreach ( $res as $row ) { |
161 | $pageID = $row->pp_page; |
162 | $pageValue = $row->pp_value; |
163 | if ( array_key_exists( $pageValue, $pagesPerValue ) ) { |
164 | $pagesPerValue[$pageValue][] = $pageID; |
165 | } else { |
166 | $pagesPerValue[$pageValue] = [ $pageID ]; |
167 | } |
168 | } |
169 | |
170 | return $pagesPerValue; |
171 | } |
172 | |
173 | /** |
174 | * Gets the template page where this table is defined - |
175 | * hopefully there's exactly one of them. |
176 | */ |
177 | public static function getTemplateIDForDBTable( $tableName ) { |
178 | $lb = MediaWikiServices::getInstance()->getDBLoadBalancer(); |
179 | $dbr = $lb->getConnectionRef( DB_REPLICA ); |
180 | $page = $dbr->selectField( 'page_props', [ |
181 | 'pp_page' |
182 | ], [ |
183 | 'pp_value' => $tableName, |
184 | 'pp_propname' => 'CargoTableName' |
185 | ] |
186 | ); |
187 | if ( !$page ) { |
188 | return null; |
189 | } |
190 | return $page; |
191 | } |
192 | |
193 | public static function formatError( $errorString ) { |
194 | return Html::element( 'div', [ 'class' => 'error' ], $errorString ); |
195 | } |
196 | |
197 | public static function displayErrorMessage( OutputPage $out, Message $message ) { |
198 | $out->wrapWikiTextAsInterface( 'error', $message->plain() ); |
199 | } |
200 | |
201 | public static function getTables() { |
202 | $tableNames = []; |
203 | $lb = MediaWikiServices::getInstance()->getDBLoadBalancer(); |
204 | $dbr = $lb->getConnectionRef( DB_REPLICA ); |
205 | $res = $dbr->select( 'cargo_tables', 'main_table' ); |
206 | foreach ( $res as $row ) { |
207 | $tableName = $row->main_table; |
208 | // Skip "replacement" tables. |
209 | if ( substr( $tableName, -6 ) == '__NEXT' ) { |
210 | continue; |
211 | } |
212 | $tableNames[] = $tableName; |
213 | } |
214 | return $tableNames; |
215 | } |
216 | |
217 | public static function getParentTables( $tableName ) { |
218 | $parentTables = []; |
219 | $lb = MediaWikiServices::getInstance()->getDBLoadBalancer(); |
220 | $dbr = $lb->getConnectionRef( DB_REPLICA ); |
221 | $res = $dbr->select( 'cargo_tables', [ 'template_id', 'main_table' ] ); |
222 | foreach ( $res as $row ) { |
223 | if ( $tableName == $row->main_table ) { |
224 | $parentTables = self::getPageProp( $row->template_id, 'CargoParentTables' ); |
225 | } |
226 | } |
227 | if ( $parentTables ) { |
228 | return unserialize( $parentTables ); |
229 | } |
230 | } |
231 | |
232 | public static function getChildTables( $tableName ) { |
233 | $childTables = []; |
234 | $allParentTablesInfo = self::getAllPageProps( 'CargoParentTables' ); |
235 | foreach ( $allParentTablesInfo as $parentTablesInfoStr => $templateIDs ) { |
236 | $parentTablesInfo = unserialize( $parentTablesInfoStr ); |
237 | foreach ( $parentTablesInfo as $parentTableInfo ) { |
238 | $remoteTable = $parentTableInfo['Name']; |
239 | if ( $remoteTable !== $tableName ) { |
240 | continue; |
241 | } |
242 | $localField = $parentTableInfo['_localField']; |
243 | $remoteField = $parentTableInfo['_remoteField']; |
244 | // There should only ever be one ID here... right? |
245 | foreach ( $templateIDs as $templateID ) { |
246 | $childTable = self::getPageProp( $templateID, 'CargoTableName' ); |
247 | $childTables[] = [ |
248 | 'childTable' => $childTable, |
249 | 'childField' => $localField, |
250 | 'parentTable' => $remoteTable, |
251 | 'parentField' => $remoteField |
252 | ]; |
253 | } |
254 | } |
255 | } |
256 | return $childTables; |
257 | } |
258 | |
259 | public static function getDrilldownTabsParams( $tableName ) { |
260 | $drilldownTabs = []; |
261 | $lb = MediaWikiServices::getInstance()->getDBLoadBalancer(); |
262 | $dbr = $lb->getConnectionRef( DB_REPLICA ); |
263 | $res = $dbr->select( 'cargo_tables', [ 'template_id', 'main_table' ] ); |
264 | foreach ( $res as $row ) { |
265 | if ( $tableName == $row->main_table ) { |
266 | $drilldownTabs = self::getPageProp( $row->template_id, 'CargoDrilldownTabsParams' ); |
267 | } |
268 | } |
269 | if ( $drilldownTabs ) { |
270 | return unserialize( $drilldownTabs ); |
271 | } |
272 | } |
273 | |
274 | public static function getTableSchemas( $tableNames ) { |
275 | $mainTableNames = []; |
276 | foreach ( $tableNames as $tableName ) { |
277 | if ( strpos( $tableName, '__' ) !== false && |
278 | strpos( $tableName, '__NEXT' ) === false ) { |
279 | // We just want the first part of it. |
280 | $tableNameParts = explode( '__', $tableName ); |
281 | $tableName = $tableNameParts[0]; |
282 | } |
283 | if ( !in_array( $tableName, $mainTableNames ) ) { |
284 | $mainTableNames[] = $tableName; |
285 | } |
286 | } |
287 | $tableSchemas = []; |
288 | $lb = MediaWikiServices::getInstance()->getDBLoadBalancer(); |
289 | $dbr = $lb->getConnectionRef( DB_REPLICA ); |
290 | $res = $dbr->select( 'cargo_tables', [ 'main_table', 'table_schema' ], |
291 | [ 'main_table' => $mainTableNames ] ); |
292 | foreach ( $res as $row ) { |
293 | $tableName = $row->main_table; |
294 | $tableSchemaString = $row->table_schema; |
295 | $tableSchemas[$tableName] = CargoTableSchema::newFromDBString( $tableSchemaString ); |
296 | } |
297 | |
298 | // Validate the table names. |
299 | if ( count( $tableSchemas ) < count( $mainTableNames ) ) { |
300 | foreach ( $mainTableNames as $tableName ) { |
301 | if ( !array_key_exists( $tableName, $tableSchemas ) ) { |
302 | throw new MWException( wfMessage( "cargo-unknowntable", $tableName )->parse() ); |
303 | } |
304 | } |
305 | } |
306 | |
307 | return $tableSchemas; |
308 | } |
309 | |
310 | /** |
311 | * Get the Cargo table for the passed-in template specified via |
312 | * either #cargo_declare or #cargo_attach, if the template has a |
313 | * call to either one. |
314 | */ |
315 | public static function getTableNameForTemplate( $templateTitle ) { |
316 | $templatePageID = $templateTitle->getArticleID(); |
317 | $declaredTableName = self::getPageProp( $templatePageID, 'CargoTableName' ); |
318 | if ( $declaredTableName !== null ) { |
319 | return [ $declaredTableName, true ]; |
320 | } |
321 | $attachedTableName = self::getPageProp( $templatePageID, 'CargoAttachedTable' ); |
322 | return [ $attachedTableName, false ]; |
323 | } |
324 | |
325 | /** |
326 | * Make the alias different from table name to avoid removal of aliases (when passed |
327 | * in to Mediawiki's select() call) if the alias and table name are the same. |
328 | * Aliases are needed because sometimes the same table can be joined more than once, if |
329 | * it serves as two different parent tables |
330 | * @param string $tableName |
331 | * @return string |
332 | */ |
333 | public static function makeDifferentAlias( $tableName ) { |
334 | $tableAlias = $tableName . "_alias"; |
335 | return $tableAlias; |
336 | } |
337 | |
338 | /** |
339 | * Splits a string by the delimiter, but ensures that parentheses, |
340 | * separators and "the other quote" (single quote in a double quoted |
341 | * string, double quote in a single quoted string, etc.) inside a quoted |
342 | * string are not considered lexically. |
343 | * @param string $delimiter The delimiter to split by. |
344 | * @param string $string The string to split. |
345 | * @param bool $includeBlankValues Whether to include blank values in the returned array. |
346 | * @return string[] Array of substrings (with or without blank values). |
347 | * @throws MWException On unmatched quotes or incomplete escape sequences. |
348 | */ |
349 | public static function smartSplit( $delimiter, $string, $includeBlankValues = false ) { |
350 | if ( $string == '' ) { |
351 | return []; |
352 | } |
353 | |
354 | $ignoreNextChar = false; |
355 | $returnValues = []; |
356 | $numOpenParentheses = 0; |
357 | $curReturnValue = ''; |
358 | $strLength = strlen( $string ); |
359 | for ( $i = 0; $i < $strLength; $i++ ) { |
360 | $curChar = $string[$i]; |
361 | |
362 | if ( $ignoreNextChar ) { |
363 | // If previous character was a backslash, |
364 | // ignore the current one, since it's escaped. |
365 | // What if this one is a backslash too? |
366 | // Doesn't matter - it's escaped. |
367 | $ignoreNextChar = false; |
368 | } elseif ( $curChar == '(' ) { |
369 | $numOpenParentheses++; |
370 | } elseif ( $curChar == ')' ) { |
371 | $numOpenParentheses--; |
372 | } elseif ( $curChar == '\'' || $curChar == '"' || $curChar == '`' ) { |
373 | $pos = self::findQuotedStringEnd( $string, $curChar, $i + 1 ); |
374 | if ( $pos === false ) { |
375 | throw new MWException( "Error: unmatched quote in SQL string constant." ); |
376 | } |
377 | $curReturnValue .= substr( $string, $i, $pos - $i ); |
378 | $i = $pos; |
379 | } elseif ( $curChar == '\\' ) { |
380 | $ignoreNextChar = true; |
381 | } |
382 | |
383 | if ( $curChar == $delimiter && $numOpenParentheses == 0 ) { |
384 | $returnValues[] = trim( $curReturnValue ); |
385 | $curReturnValue = ''; |
386 | } else { |
387 | $curReturnValue .= $curChar; |
388 | } |
389 | } |
390 | $returnValues[] = trim( $curReturnValue ); |
391 | |
392 | if ( $ignoreNextChar ) { |
393 | throw new MWException( "Error: incomplete escape sequence." ); |
394 | } |
395 | |
396 | if ( $includeBlankValues ) { |
397 | return $returnValues; |
398 | } |
399 | |
400 | // Remove empty strings (but not other quasi-empty values, like '0') and re-key the array. |
401 | $noEmptyStrings = static function ( $s ) { |
402 | return $s !== ''; |
403 | }; |
404 | return array_values( array_filter( $returnValues, $noEmptyStrings ) ); |
405 | } |
406 | |
407 | /** |
408 | * Finds the end of a quoted string. |
409 | */ |
410 | public static function findQuotedStringEnd( $string, $quoteChar, $pos ) { |
411 | $ignoreNextChar = false; |
412 | $strLength = strlen( $string ); |
413 | for ( $i = $pos; $i < $strLength; $i++ ) { |
414 | $curChar = $string[$i]; |
415 | if ( $ignoreNextChar ) { |
416 | $ignoreNextChar = false; |
417 | } elseif ( $curChar == $quoteChar ) { |
418 | if ( $i + 1 < $strLength && $string[$i + 1] == $quoteChar ) { |
419 | $i++; |
420 | } else { |
421 | return $i; |
422 | } |
423 | } elseif ( $curChar == '\\' ) { |
424 | $ignoreNextChar = true; |
425 | } |
426 | } |
427 | if ( $ignoreNextChar ) { |
428 | throw new MWException( "Error: incomplete escape sequence." ); |
429 | } |
430 | return false; |
431 | } |
432 | |
433 | /** |
434 | * Deletes text within quotes and raises and exception if a quoted string |
435 | * is not closed. |
436 | */ |
437 | public static function removeQuotedStrings( ?string $string ): string { |
438 | if ( $string === null ) { |
439 | return ''; |
440 | } |
441 | |
442 | $noQuotesPattern = '/("|\')([^\\1\\\\]|\\\\.)*?\\1/s'; |
443 | $string = preg_replace( $noQuotesPattern, '', $string ); |
444 | if ( strpos( $string, '"' ) !== false || strpos( $string, "'" ) !== false ) { |
445 | throw new MWException( "Error: unclosed string literal." ); |
446 | } |
447 | return $string; |
448 | } |
449 | |
450 | /** |
451 | * Get rid of the "File:" or "Image:" (in the wiki's language) at the |
452 | * beginning of a file name, if it's there. |
453 | */ |
454 | public static function removeNamespaceFromFileName( $fileName ) { |
455 | $fileTitle = Title::newFromText( $fileName, NS_FILE ); |
456 | if ( $fileTitle == null ) { |
457 | return null; |
458 | } |
459 | return $fileTitle->getText(); |
460 | } |
461 | |
462 | /** |
463 | * Generates a Regular Expression to match $fieldName in a SQL string. |
464 | * Allows for $ as valid identifier character. |
465 | */ |
466 | public static function getSQLFieldPattern( $fieldName, $closePattern = true ) { |
467 | $fieldName = str_replace( '$', '\$', $fieldName ); |
468 | $pattern = '/([^\w$.,]|^)' . $fieldName; |
469 | return $pattern . ( $closePattern ? '([^\w$]|$)/' : '' ); |
470 | } |
471 | |
472 | /** |
473 | * Generates a Regular Expression to match $tableName.$fieldName in a |
474 | * SQL string. Allows for $ as valid identifier character. |
475 | */ |
476 | public static function getSQLTableAndFieldPattern( $tableName, $fieldName, $closePattern = true ) { |
477 | $fieldName = str_replace( '$', '\$', $fieldName ); |
478 | $tableName = str_replace( '$', '\$', $tableName ); |
479 | $pattern = '/([^\w$,]|^)' . $tableName . '\.' . $fieldName; |
480 | return $pattern . ( $closePattern ? '([^\w$]|$)/ui' : '' ); |
481 | } |
482 | |
483 | /** |
484 | * Generates a Regular Expression to match $tableName in a SQL string. |
485 | * Allows for $ as valid identifier character. |
486 | */ |
487 | public static function getSQLTablePattern( $tableName, $closePattern = true ) { |
488 | $tableName = str_replace( '$', '\$', $tableName ); |
489 | $pattern = '/([^\w$]|^)(' . $tableName . ')\.(\w*)'; |
490 | return $pattern . ( $closePattern ? '/ui' : '' ); |
491 | } |
492 | |
493 | /** |
494 | * Determines whether a string is a literal. |
495 | * This may need different handling for different (non-MySQL) DB types. |
496 | */ |
497 | public static function isSQLStringLiteral( $string ) { |
498 | return $string[0] == "'" && substr( $string, -1, 1 ) == "'"; |
499 | } |
500 | |
501 | public static function getDateFunctions( $dateDBField ) { |
502 | global $wgCargoDBtype; |
503 | |
504 | // Unfortunately, date handling in general - and date extraction |
505 | // specifically - is done differently in almost every DB |
506 | // system. If support was ever added for SQLite, |
507 | // that would require special handling as well. |
508 | if ( $wgCargoDBtype == 'postgres' ) { |
509 | $yearValue = "DATE_PART('year', $dateDBField)"; |
510 | $monthValue = "DATE_PART('month', $dateDBField)"; |
511 | $dayValue = "DATE_PART('day', $dateDBField)"; |
512 | } else { // MySQL |
513 | $yearValue = "YEAR($dateDBField)"; |
514 | $monthValue = "MONTH($dateDBField)"; |
515 | $dayValue = "DAY($dateDBField)"; |
516 | } |
517 | return [ $yearValue, $monthValue, $dayValue ]; |
518 | } |
519 | |
520 | /** |
521 | * Parses a piece of wikitext differently depending on whether |
522 | * we're in a special or regular page. |
523 | * |
524 | * @param string $value |
525 | * @param Parser|null $parser |
526 | * @return string |
527 | */ |
528 | public static function smartParse( $value, $parser ) { |
529 | global $wgRequest; |
530 | |
531 | // This decode() call is here in case the value was |
532 | // set using {{PAGENAME}}, which for some reason |
533 | // HTML-encodes some of its characters - see |
534 | // https://www.mediawiki.org/wiki/Help:Magic_words#Page_names |
535 | // Of course, String and Page fields could be set using |
536 | // {{PAGENAME}} as well, but those seem less likely. |
537 | $value = htmlspecialchars_decode( $value ); |
538 | |
539 | // Add a newline at the beginning if it looks like the value |
540 | // starts with a bulleted or numbered list, to make sure that |
541 | // the first line gets formatted correctly. |
542 | if ( strpos( $value, '*' ) === 0 || strpos( $value, '#' ) === 0 ) { |
543 | $value = "\n" . $value; |
544 | } |
545 | |
546 | // Parse it as if it's wikitext. The exact call |
547 | // depends on whether we're in a special page or not. |
548 | if ( $parser === null ) { |
549 | $parser = MediaWikiServices::getInstance()->getParser(); |
550 | } |
551 | |
552 | // Parser::getTitle() throws a TypeError if it would have |
553 | // returned null, so just catch the error. |
554 | // Why would the title be null? It's not clear, but it seems to |
555 | // happen in at least once case: in "action=pagevalues" for a |
556 | // page with non-ASCII characters in its name. |
557 | try { |
558 | $title = $parser->getTitle(); |
559 | } catch ( TypeError $e ) { |
560 | $title = null; |
561 | } |
562 | |
563 | if ( $title === null ) { |
564 | global $wgTitle; |
565 | $title = $wgTitle; |
566 | } |
567 | |
568 | if ( $title != null && $title->isSpecial( 'RunJobs' ) ) { |
569 | // Conveniently, if this is called from within a job |
570 | // being run, the name of the page will be |
571 | // Special:RunJobs. |
572 | // If that's the case, do nothing - we don't need to |
573 | // parse the value. |
574 | // This next clause should only be called for Cargo's special |
575 | // pages, not for SF's Special:RunQuery. Don't know about other |
576 | // special pages. |
577 | } elseif ( ( $title != null && $title->isSpecialPage() && !$wgRequest->getCheck( 'wpRunQuery' ) ) || |
578 | // The 'pagevalues' action is also a Cargo special page. |
579 | $wgRequest->getVal( 'action' ) == 'pagevalues' ) { |
580 | $parserOptions = ParserOptions::newFromAnon(); |
581 | $parserForInnerParse = MediaWikiServices::getInstance()->getParserFactory()->create(); |
582 | $parserOutput = $parserForInnerParse->parse( $value, $title, $parserOptions ); |
583 | $value = $parserOutput->getText( [ 'unwrap' => true ] ); |
584 | } else { |
585 | $value = $parser->internalParse( $value ); |
586 | } |
587 | return $value; |
588 | } |
589 | |
590 | public static function parsePageForStorage( $title, $pageContents ) { |
591 | // Special handling for the Approved Revs extension. |
592 | $approvedContent = null; |
593 | if ( class_exists( 'ApprovedRevs' ) ) { |
594 | $approvedContent = ApprovedRevs::getApprovedContent( $title ); |
595 | } |
596 | if ( $approvedContent != null ) { |
597 | if ( method_exists( $approvedContent, 'getText' ) ) { |
598 | // Approved Revs 1.0+ |
599 | $pageText = $approvedContent->getText(); |
600 | } else { |
601 | $pageText = $approvedContent; |
602 | } |
603 | } else { |
604 | $pageText = $pageContents; |
605 | } |
606 | $parser = MediaWikiServices::getInstance()->getParser(); |
607 | $parserOptions = ParserOptions::newFromAnon(); |
608 | $parser->parse( $pageText, $title, $parserOptions ); |
609 | } |
610 | |
611 | /** |
612 | * Drop, and then create again, the database table(s) holding the |
613 | * data for this template. |
614 | * Why "tables"? Because every field that holds a list of values gets |
615 | * its own helper table. |
616 | * |
617 | * @param int $templatePageID |
618 | * @return bool |
619 | * @throws MWException |
620 | */ |
621 | public static function recreateDBTablesForTemplate( |
622 | $templatePageID, |
623 | $createReplacement, |
624 | User $user, |
625 | $tableName = null, |
626 | $tableSchema = null, |
627 | $parentTables = null |
628 | ) { |
629 | if ( $tableName == null ) { |
630 | $tableName = self::getPageProp( $templatePageID, 'CargoTableName' ); |
631 | } |
632 | |
633 | if ( $tableSchema == null ) { |
634 | $tableSchemaString = self::getPageProp( $templatePageID, 'CargoFields' ); |
635 | // First, see if there even is DB storage for this template - |
636 | // if not, exit. |
637 | if ( $tableSchemaString === null ) { |
638 | return false; |
639 | } |
640 | $tableSchema = CargoTableSchema::newFromDBString( $tableSchemaString ); |
641 | } else { |
642 | $tableSchemaString = $tableSchema->toDBString(); |
643 | } |
644 | |
645 | $dbw = self::getMainDBForWrite(); |
646 | $cdb = self::getDB(); |
647 | |
648 | // Cannot run any recreate if a replacement table exists. |
649 | $possibleReplacementTable = $tableName . '__NEXT'; |
650 | if ( self::tableFullyExists( $tableName ) && self::tableFullyExists( $possibleReplacementTable ) ) { |
651 | throw new MWException( wfMessage( 'cargo-recreatedata-replacementexists', $tableName, $possibleReplacementTable )->parse() ); |
652 | } |
653 | |
654 | if ( $createReplacement ) { |
655 | $tableName .= '__NEXT'; |
656 | if ( $cdb->tableExists( $possibleReplacementTable ) ) { |
657 | // The replacement table exists, but it does |
658 | // not have a row in cargo_tables - this is |
659 | // hopefully a rare occurrence. |
660 | try { |
661 | $cdb->begin(); |
662 | $cdb->dropTable( $tableName ); |
663 | $cdb->commit(); |
664 | } catch ( Exception $e ) { |
665 | throw new MWException( "Caught exception ($e) while trying to drop Cargo table. " |
666 | . "Please make sure that your database user account has the DROP permission." ); |
667 | } |
668 | } |
669 | } else { |
670 | // @TODO - is an array really necessary? Shouldn't it |
671 | // always be just one table name? Tied in with that, |
672 | // if a table name was already specified, do we need |
673 | // to do a lookup here? |
674 | $tableNames = []; |
675 | $res = $dbw->select( 'cargo_tables', 'main_table', [ 'template_id' => $templatePageID ] ); |
676 | foreach ( $res as $row ) { |
677 | $tableNames[] = $row->main_table; |
678 | } |
679 | |
680 | // For whatever reason, that DB query might have failed - |
681 | // if so, just add the table name here. |
682 | if ( $tableName != null && !in_array( $tableName, $tableNames ) ) { |
683 | $tableNames[] = $tableName; |
684 | } |
685 | |
686 | $mainTableAlreadyExists = self::tableFullyExists( $tableNames[0] ); |
687 | foreach ( $tableNames as $curTable ) { |
688 | try { |
689 | $cdb->begin(); |
690 | $cdb->dropTable( $curTable ); |
691 | $cdb->commit(); |
692 | } catch ( Exception $e ) { |
693 | throw new MWException( "Caught exception ($e) while trying to drop Cargo table. " |
694 | . "Please make sure that your database user account has the DROP permission." ); |
695 | } |
696 | $dbw->delete( 'cargo_pages', [ 'table_name' => $curTable ] ); |
697 | } |
698 | |
699 | $dbw->delete( 'cargo_tables', [ 'template_id' => $templatePageID ] ); |
700 | } |
701 | |
702 | self::createCargoTableOrTables( $cdb, $dbw, $tableName, $tableSchema, $tableSchemaString, $templatePageID ); |
703 | |
704 | if ( !$createReplacement ) { |
705 | // Log this. |
706 | if ( $mainTableAlreadyExists ) { |
707 | self::logTableAction( 'recreatetable', $tableName, $user ); |
708 | } else { |
709 | self::logTableAction( 'createtable', $tableName, $user ); |
710 | } |
711 | } |
712 | |
713 | return true; |
714 | } |
715 | |
716 | public static function tableFullyExists( $tableName ) { |
717 | $lb = MediaWikiServices::getInstance()->getDBLoadBalancer(); |
718 | $dbr = $lb->getConnectionRef( DB_REPLICA ); |
719 | $numRows = $dbr->selectRowCount( 'cargo_tables', '*', [ 'main_table' => $tableName ], __METHOD__ ); |
720 | if ( $numRows == 0 ) { |
721 | return false; |
722 | } |
723 | |
724 | $cdb = self::getDB(); |
725 | return $cdb->tableExists( $tableName ); |
726 | } |
727 | |
728 | public static function fieldTypeToSQLType( $fieldType, $dbType, $size = null ) { |
729 | global $wgCargoDefaultStringBytes; |
730 | |
731 | // Possible values for $dbType: "mysql", "postgres", "sqlite" |
732 | // @TODO - make sure it's one of these. |
733 | if ( $fieldType == 'Integer' ) { |
734 | switch ( $dbType ) { |
735 | case "mysql": |
736 | case "postgres": |
737 | return 'Int'; |
738 | case "sqlite": |
739 | return 'INTEGER'; |
740 | } |
741 | } elseif ( $fieldType == 'Float' || $fieldType == 'Rating' ) { |
742 | switch ( $dbType ) { |
743 | case "mysql": |
744 | return 'Double'; |
745 | case "postgres": |
746 | return 'Numeric'; |
747 | case "sqlite": |
748 | return 'REAL'; |
749 | } |
750 | } elseif ( $fieldType == 'Boolean' ) { |
751 | switch ( $dbType ) { |
752 | case "mysql": |
753 | case "postgres": |
754 | return 'Boolean'; |
755 | case "sqlite": |
756 | return 'INTEGER'; |
757 | } |
758 | } elseif ( $fieldType == 'Date' || $fieldType == 'Start date' || $fieldType == 'End date' ) { |
759 | switch ( $dbType ) { |
760 | case "mysql": |
761 | case "postgres": |
762 | return 'Date'; |
763 | case "sqlite": |
764 | // Should really be 'REAL', with |
765 | // accompanying handling. |
766 | return 'TEXT'; |
767 | } |
768 | } elseif ( $fieldType == 'Datetime' || $fieldType == 'Start datetime' || $fieldType == 'End datetime' ) { |
769 | // Some DB types have a datetime type that includes |
770 | // the time zone, but MySQL unfortunately doesn't, |
771 | // so the best solution for time zones is probably |
772 | // to have a separate field for them. |
773 | switch ( $dbType ) { |
774 | case "mysql": |
775 | return 'Datetime'; |
776 | case "postgres": |
777 | return 'Timestamp'; |
778 | case "sqlite": |
779 | // Should really be 'REAL', with |
780 | // accompanying handling. |
781 | return 'TEXT'; |
782 | } |
783 | } elseif ( $fieldType == 'Text' || $fieldType == 'Wikitext' ) { |
784 | switch ( $dbType ) { |
785 | case "mysql": |
786 | case "postgres": |
787 | case "sqlite": |
788 | return 'Text'; |
789 | } |
790 | } elseif ( $fieldType == 'Searchtext' ) { |
791 | if ( $dbType != 'mysql' ) { |
792 | throw new MWException( "Error: a \"Searchtext\" field can currently only be defined for MySQL databases." ); |
793 | } |
794 | return 'Mediumtext'; |
795 | } else { // 'String', 'Page', 'Wikitext string', etc. |
796 | if ( $size == null ) { |
797 | $size = $wgCargoDefaultStringBytes; |
798 | } |
799 | switch ( $dbType ) { |
800 | case "mysql": |
801 | case "postgres": |
802 | // For at least MySQL, there's a limit |
803 | // on how many total bytes a table's |
804 | // fields can have, and "Text" and |
805 | // "Blob" fields don't get added to the |
806 | // total, so if it's a big piece of |
807 | // text, just make it a "Text" field. |
808 | if ( $size > 1000 ) { |
809 | return 'Text'; |
810 | } else { |
811 | return "Varchar($size)"; |
812 | } |
813 | case "sqlite": |
814 | return 'TEXT'; |
815 | } |
816 | } |
817 | } |
818 | |
819 | public static function createCargoTableOrTables( $cdb, $dbw, $tableName, $tableSchema, $tableSchemaString, $templatePageID ) { |
820 | $cdb->begin(); |
821 | $fieldsInMainTable = [ |
822 | '_ID' => 'Integer', |
823 | '_pageName' => 'String', |
824 | '_pageTitle' => 'String', |
825 | '_pageNamespace' => 'Integer', |
826 | '_pageID' => 'Integer', |
827 | ]; |
828 | |
829 | $containsFileType = false; |
830 | foreach ( $tableSchema->mFieldDescriptions as $fieldName => $fieldDescription ) { |
831 | $isList = $fieldDescription->mIsList; |
832 | $fieldType = $fieldDescription->mType; |
833 | |
834 | if ( $isList || $fieldType == 'Coordinates' ) { |
835 | // No field will be created with this name - |
836 | // instead, we'll have one called |
837 | // fieldName + '__full', and a separate table |
838 | // for holding each value. |
839 | // The field holding the full list will always |
840 | // just be text - and it could be long. |
841 | $fieldsInMainTable[$fieldName . '__full'] = 'Text'; |
842 | } else { |
843 | $fieldsInMainTable[$fieldName] = $fieldDescription; |
844 | } |
845 | |
846 | if ( !$isList && $fieldType == 'Coordinates' ) { |
847 | $fieldsInMainTable[$fieldName . '__lat'] = 'Float'; |
848 | $fieldsInMainTable[$fieldName . '__lon'] = 'Float'; |
849 | } elseif ( $fieldType == 'Date' || $fieldType == 'Datetime' || |
850 | $fieldType == 'Start date' || $fieldType == 'Start datetime' || |
851 | $fieldType == 'End date' || $fieldType == 'End datetime' ) { |
852 | $fieldsInMainTable[$fieldName . '__precision'] = 'Integer'; |
853 | } elseif ( $fieldType == 'File' ) { |
854 | $containsFileType = true; |
855 | } |
856 | } |
857 | |
858 | self::createTable( $cdb, $tableName, $fieldsInMainTable ); |
859 | |
860 | // Now also create tables for each of the 'list' fields, |
861 | // if there are any. |
862 | $fieldTableNames = []; // Names of tables that store data regarding pages |
863 | $fieldHelperTableNames = []; // Names of tables that store metadata regarding template or fields |
864 | foreach ( $tableSchema->mFieldDescriptions as $fieldName => $fieldDescription ) { |
865 | if ( $fieldDescription->mIsList ) { |
866 | // The double underscore in this table name |
867 | // should prevent anyone from giving this name |
868 | // to a "real" table. |
869 | $fieldTableName = $tableName . '__' . $fieldName; |
870 | $cdb->dropTable( $fieldTableName ); |
871 | |
872 | $fieldsInTable = [ '_rowID' => 'Integer' ]; |
873 | $fieldType = $fieldDescription->mType; |
874 | if ( $fieldType == 'Coordinates' ) { |
875 | $fieldsInTable['_lat'] = 'Float'; |
876 | $fieldsInTable['_lon'] = 'Float'; |
877 | } else { |
878 | $fieldsInTable['_value'] = $fieldType; |
879 | } |
880 | if ( $fieldDescription->isDateOrDateTime() ) { |
881 | $fieldsInTable['_value__precision'] = 'Integer'; |
882 | } |
883 | $fieldsInTable['_position'] = 'Integer'; |
884 | |
885 | self::createTable( $cdb, $fieldTableName, $fieldsInTable ); |
886 | $fieldTableNames[] = $fieldTableName; |
887 | } |
888 | if ( $fieldDescription->mIsHierarchy ) { |
889 | $fieldHelperTableName = $tableName . '__' . $fieldName . '__hierarchy'; |
890 | $cdb->dropTable( $fieldHelperTableName ); |
891 | $fieldType = $fieldDescription->mType; |
892 | $fieldsInTable = [ |
893 | '_value' => $fieldType, |
894 | '_left' => 'Integer', |
895 | '_right' => 'Integer', |
896 | ]; |
897 | self::createTable( $cdb, $fieldHelperTableName, $fieldsInTable, true ); |
898 | |
899 | $fieldHelperTableNames[] = $fieldHelperTableName; |
900 | // Insert hierarchy information in the __hierarchy table |
901 | $hierarchyTree = CargoHierarchyTree::newFromWikiText( $fieldDescription->mHierarchyStructure ); |
902 | $hierarchyStructureTableData = $hierarchyTree->generateHierarchyStructureTableData(); |
903 | foreach ( $hierarchyStructureTableData as $entry ) { |
904 | $cdb->insert( $fieldHelperTableName, $entry ); |
905 | } |
906 | } |
907 | } |
908 | |
909 | // And create a helper table holding all the files stored in |
910 | // this table, if there are any. |
911 | if ( $containsFileType ) { |
912 | $fileTableName = $tableName . '___files'; |
913 | $cdb->dropTable( $fileTableName ); |
914 | $fieldsInTable = [ |
915 | '_pageName' => 'String', |
916 | '_pageID' => 'Integer', |
917 | '_fieldName' => 'String', |
918 | '_fileName' => 'String' |
919 | ]; |
920 | self::createTable( $cdb, $fileTableName, $fieldsInTable ); |
921 | } |
922 | |
923 | // End transaction and apply DB changes. |
924 | $cdb->commit(); |
925 | |
926 | // Finally, store all the info in the cargo_tables table. |
927 | $dbw->insert( 'cargo_tables', [ |
928 | 'template_id' => $templatePageID, |
929 | 'main_table' => $tableName, |
930 | 'field_tables' => serialize( $fieldTableNames ), |
931 | 'field_helper_tables' => serialize( $fieldHelperTableNames ), |
932 | 'table_schema' => $tableSchemaString |
933 | ] ); |
934 | } |
935 | |
936 | public static function createTable( $cdb, $tableName, $fieldsInTable, $multipleColumnIndex = false ) { |
937 | global $wgCargoDBRowFormat; |
938 | |
939 | // Unfortunately, there is not yet a 'CREATE TABLE' wrapper |
940 | // in the MediaWiki DB API, so we have to call SQL directly. |
941 | $dbType = $cdb->getType(); |
942 | $sqlTableName = $cdb->tableName( $tableName ); |
943 | $createSQL = "CREATE TABLE $sqlTableName ( "; |
944 | $firstField = true; |
945 | foreach ( $fieldsInTable as $fieldName => $fieldDescOrType ) { |
946 | $fieldOptionsText = ''; |
947 | if ( is_object( $fieldDescOrType ) ) { |
948 | $fieldType = $fieldDescOrType->mType; |
949 | $fieldSize = $fieldDescOrType->mSize; |
950 | $sqlType = self::fieldTypeToSQLType( $fieldType, $dbType, $fieldSize ); |
951 | |
952 | if ( $fieldDescOrType->mIsMandatory ) { |
953 | $fieldOptionsText .= ' NOT NULL'; |
954 | } |
955 | if ( $fieldDescOrType->mIsUnique ) { |
956 | $fieldOptionsText .= ' UNIQUE'; |
957 | } |
958 | } else { |
959 | $fieldType = $fieldDescOrType; |
960 | $sqlType = self::fieldTypeToSQLType( $fieldType, $dbType ); |
961 | if ( $fieldName == '_ID' ) { |
962 | $fieldOptionsText .= ' PRIMARY KEY'; |
963 | } elseif ( $fieldName == '_rowID' ) { |
964 | $fieldOptionsText .= ' NOT NULL'; |
965 | } |
966 | } |
967 | if ( $firstField ) { |
968 | $firstField = false; |
969 | } else { |
970 | $createSQL .= ', '; |
971 | } |
972 | $sqlFieldName = $cdb->addIdentifierQuotes( $fieldName ); |
973 | $createSQL .= "$sqlFieldName $sqlType $fieldOptionsText"; |
974 | if ( $fieldType == 'Searchtext' ) { |
975 | $createSQL .= ", FULLTEXT KEY $fieldName ( $sqlFieldName )"; |
976 | } |
977 | } |
978 | |
979 | $createSQL .= ' )'; |
980 | // Allow for setting a format like COMPRESSED, DYNAMIC etc. |
981 | if ( $wgCargoDBRowFormat != null ) { |
982 | $createSQL .= " ROW_FORMAT=$wgCargoDBRowFormat"; |
983 | } |
984 | $cdb->query( $createSQL ); |
985 | |
986 | // Add an index for any field that's not of type Text, |
987 | // Searchtext or Wikitext. |
988 | $indexedFields = []; |
989 | foreach ( $fieldsInTable as $fieldName => $fieldDescOrType ) { |
990 | // We don't need to index _ID, because it's already |
991 | // the primary key. |
992 | if ( $fieldName == '_ID' ) { |
993 | continue; |
994 | } |
995 | |
996 | // @HACK - MySQL does not allow more than 64 keys/ |
997 | // indexes per table. We are indexing most fields - |
998 | // so if a table has more than 64 fields, there's a |
999 | // good chance that it will overrun this limit. |
1000 | // So we just stop indexing after the first 60. |
1001 | if ( count( $indexedFields ) >= 60 ) { |
1002 | break; |
1003 | } |
1004 | |
1005 | if ( is_object( $fieldDescOrType ) ) { |
1006 | $fieldType = $fieldDescOrType->mType; |
1007 | } else { |
1008 | $fieldType = $fieldDescOrType; |
1009 | } |
1010 | if ( in_array( $fieldType, [ 'Text', 'Searchtext', 'Wikitext' ] ) ) { |
1011 | continue; |
1012 | } |
1013 | $indexedFields[] = $fieldName; |
1014 | } |
1015 | |
1016 | if ( $multipleColumnIndex ) { |
1017 | $indexName = "nested_set_$tableName"; |
1018 | $sqlFieldNames = array_map( |
1019 | [ $cdb, 'addIdentifierQuotes' ], |
1020 | $indexedFields |
1021 | ); |
1022 | $sqlFieldNamesStr = implode( ', ', $sqlFieldNames ); |
1023 | $createIndexSQL = "CREATE INDEX $indexName ON " . |
1024 | "$sqlTableName ($sqlFieldNamesStr)"; |
1025 | $cdb->query( $createIndexSQL ); |
1026 | } else { |
1027 | foreach ( $indexedFields as $fieldName ) { |
1028 | $indexName = $fieldName . '_' . $tableName; |
1029 | // MySQL doesn't allow index names with more than 64 characters. |
1030 | $indexName = substr( $indexName, 0, 64 ); |
1031 | $sqlFieldName = $cdb->addIdentifierQuotes( $fieldName ); |
1032 | $sqlIndexName = $cdb->addIdentifierQuotes( $indexName ); |
1033 | $createIndexSQL = "CREATE INDEX $sqlIndexName ON " . |
1034 | "$sqlTableName ($sqlFieldName)"; |
1035 | $cdb->query( $createIndexSQL ); |
1036 | } |
1037 | } |
1038 | } |
1039 | |
1040 | public static function specialTableNames() { |
1041 | $specialTableNames = [ '_pageData', '_fileData' ]; |
1042 | if ( class_exists( 'FDGanttContent' ) ) { |
1043 | // The Flex Diagrams extension is installed. |
1044 | $specialTableNames[] = '_bpmnData'; |
1045 | $specialTableNames[] = '_ganttData'; |
1046 | } |
1047 | return $specialTableNames; |
1048 | } |
1049 | |
1050 | public static function fullTextMatchSQL( $cdb, $tableName, $fieldName, $searchTerm ) { |
1051 | $fullFieldName = self::escapedFieldName( $cdb, $tableName, $fieldName ); |
1052 | $searchTerm = $cdb->addQuotes( $searchTerm ); |
1053 | return " MATCH($fullFieldName) AGAINST ($searchTerm IN BOOLEAN MODE) "; |
1054 | } |
1055 | |
1056 | /** |
1057 | * Parses one half of a set of coordinates into a number. |
1058 | * |
1059 | * Copied from Miga, also written by Yaron Koren |
1060 | * (https://github.com/yaronkoren/miga/blob/master/MDVCoordinates.js) |
1061 | * - though that one is in Javascript. |
1062 | */ |
1063 | public static function coordinatePartToNumber( $coordinateStr ) { |
1064 | $degreesSymbols = [ "\x{00B0}", "d" ]; |
1065 | $minutesSymbols = [ "'", "\x{2032}", "\x{00B4}" ]; |
1066 | $secondsSymbols = [ '"', "\x{2033}", "\x{00B4}\x{00B4}" ]; |
1067 | |
1068 | $numDegrees = null; |
1069 | $numMinutes = null; |
1070 | $numSeconds = null; |
1071 | |
1072 | foreach ( $degreesSymbols as $degreesSymbol ) { |
1073 | $pattern = '/([\d\.]+)' . $degreesSymbol . '/u'; |
1074 | if ( preg_match( $pattern, $coordinateStr, $matches ) ) { |
1075 | $numDegrees = floatval( $matches[1] ); |
1076 | break; |
1077 | } |
1078 | } |
1079 | if ( $numDegrees == null ) { |
1080 | throw new MWException( "Error: could not parse degrees in \"$coordinateStr\"." ); |
1081 | } |
1082 | |
1083 | foreach ( $minutesSymbols as $minutesSymbol ) { |
1084 | $pattern = '/([\d\.]+)' . $minutesSymbol . '/u'; |
1085 | if ( preg_match( $pattern, $coordinateStr, $matches ) ) { |
1086 | $numMinutes = floatval( $matches[1] ); |
1087 | break; |
1088 | } |
1089 | } |
1090 | if ( $numMinutes == null ) { |
1091 | // This might not be an error - the number of minutes |
1092 | // might just not have been set. |
1093 | $numMinutes = 0; |
1094 | } |
1095 | |
1096 | foreach ( $secondsSymbols as $secondsSymbol ) { |
1097 | $pattern = '/(\d+)' . $secondsSymbol . '/u'; |
1098 | if ( preg_match( $pattern, $coordinateStr, $matches ) ) { |
1099 | $numSeconds = floatval( $matches[1] ); |
1100 | break; |
1101 | } |
1102 | } |
1103 | if ( $numSeconds == null ) { |
1104 | // This might not be an error - the number of seconds |
1105 | // might just not have been set. |
1106 | $numSeconds = 0; |
1107 | } |
1108 | |
1109 | return ( $numDegrees + ( $numMinutes / 60 ) + ( $numSeconds / 3600 ) ); |
1110 | } |
1111 | |
1112 | /** |
1113 | * Parses a coordinate string in (hopefully) any standard format. |
1114 | * |
1115 | * Copied from Miga, also written by Yaron Koren |
1116 | * (https://github.com/yaronkoren/miga/blob/master/MDVCoordinates.js) |
1117 | * - though that one is in Javascript. |
1118 | */ |
1119 | public static function parseCoordinatesString( $coordinatesString ) { |
1120 | $coordinatesString = trim( $coordinatesString ); |
1121 | if ( $coordinatesString === '' ) { |
1122 | // FIXME: No caller expects this! |
1123 | return; |
1124 | } |
1125 | |
1126 | // This is safe to do, right? |
1127 | $coordinatesString = str_replace( [ '[', ']' ], '', $coordinatesString ); |
1128 | // See if they're separated by commas. |
1129 | if ( strpos( $coordinatesString, ',' ) > 0 ) { |
1130 | $latAndLonStrings = explode( ',', $coordinatesString ); |
1131 | } else { |
1132 | // If there are no commas, the first half, for the |
1133 | // latitude, should end with either 'N' or 'S', so do a |
1134 | // little hack to split up the two halves. |
1135 | $coordinatesString = str_replace( [ 'N', 'S' ], [ 'N,', 'S,' ], $coordinatesString ); |
1136 | $latAndLonStrings = explode( ',', $coordinatesString ); |
1137 | } |
1138 | |
1139 | if ( count( $latAndLonStrings ) != 2 ) { |
1140 | throw new MWException( "Error parsing coordinates string: \"$coordinatesString\"." ); |
1141 | } |
1142 | [ $latString, $lonString ] = $latAndLonStrings; |
1143 | |
1144 | // Handle strings one at a time. |
1145 | $latIsNegative = false; |
1146 | if ( strpos( $latString, 'S' ) > 0 ) { |
1147 | $latIsNegative = true; |
1148 | } |
1149 | $latString = str_replace( [ 'N', 'S' ], '', $latString ); |
1150 | if ( is_numeric( $latString ) ) { |
1151 | $latNum = floatval( $latString ); |
1152 | } else { |
1153 | $latNum = self::coordinatePartToNumber( $latString ); |
1154 | } |
1155 | if ( $latIsNegative ) { |
1156 | $latNum *= -1; |
1157 | } |
1158 | |
1159 | $lonIsNegative = false; |
1160 | if ( strpos( $lonString, 'W' ) > 0 ) { |
1161 | $lonIsNegative = true; |
1162 | } |
1163 | $lonString = str_replace( [ 'E', 'W' ], '', $lonString ); |
1164 | if ( is_numeric( $lonString ) ) { |
1165 | $lonNum = floatval( $lonString ); |
1166 | } else { |
1167 | $lonNum = self::coordinatePartToNumber( $lonString ); |
1168 | } |
1169 | if ( $lonIsNegative ) { |
1170 | $lonNum *= -1; |
1171 | } |
1172 | |
1173 | return [ $latNum, $lonNum ]; |
1174 | } |
1175 | |
1176 | public static function escapedFieldName( $cdb, $tableName, $fieldName ) { |
1177 | if ( is_array( $tableName ) ) { |
1178 | $tableAlias = key( $tableName ); |
1179 | return $cdb->addIdentifierQuotes( $tableAlias ) . '.' . |
1180 | $cdb->addIdentifierQuotes( $fieldName ); |
1181 | } |
1182 | return $cdb->tableName( $tableName ) . '.' . |
1183 | $cdb->addIdentifierQuotes( $fieldName ); |
1184 | } |
1185 | |
1186 | public static function joinOfMainAndFieldTable( $cdb, $mainTableName, $fieldTableName ) { |
1187 | return [ |
1188 | 'LEFT OUTER JOIN', |
1189 | self::escapedFieldName( $cdb, $mainTableName, '_ID' ) . |
1190 | ' = ' . |
1191 | self::escapedFieldName( $cdb, $fieldTableName, '_rowID' ) |
1192 | ]; |
1193 | } |
1194 | |
1195 | public static function joinOfMainAndParentTable( $cdb, $mainTable, $mainTableField, |
1196 | $parentTable, $parentTableField ) { |
1197 | return [ |
1198 | 'LEFT OUTER JOIN', |
1199 | self::escapedFieldName( $cdb, $mainTable, $mainTableField ) . |
1200 | ' = ' . |
1201 | self::escapedFieldName( $cdb, $parentTable, $parentTableField ) |
1202 | ]; |
1203 | } |
1204 | |
1205 | public static function joinOfFieldAndMainTable( $cdb, $fieldTable, $mainTable, |
1206 | $isHierarchy = false, $hierarchyFieldName = null ) { |
1207 | if ( $isHierarchy ) { |
1208 | return [ |
1209 | 'LEFT OUTER JOIN', |
1210 | self::escapedFieldName( $cdb, $fieldTable, '_value' ) . ' = ' . |
1211 | self::escapedFieldName( $cdb, $mainTable, $hierarchyFieldName ), |
1212 | ]; |
1213 | } else { |
1214 | return [ |
1215 | 'LEFT OUTER JOIN', |
1216 | self::escapedFieldName( $cdb, $fieldTable, '_rowID' ) . ' = ' . |
1217 | self::escapedFieldName( $cdb, $mainTable, '_ID' ), |
1218 | ]; |
1219 | } |
1220 | } |
1221 | |
1222 | public static function joinOfSingleFieldAndHierarchyTable( $cdb, $singleFieldTableName, $fieldColumnName, $hierarchyTableName ) { |
1223 | return [ |
1224 | 'LEFT OUTER JOIN', |
1225 | self::escapedFieldName( $cdb, $singleFieldTableName, $fieldColumnName ) . |
1226 | ' = ' . |
1227 | self::escapedFieldName( $cdb, $hierarchyTableName, '_value' ) |
1228 | ]; |
1229 | } |
1230 | |
1231 | public static function escapedInsert( $db, $tableName, $fieldValues ) { |
1232 | // Put quotes around the field names - needed for Postgres, |
1233 | // which otherwise lowercases all field names. |
1234 | $quotedFieldValues = []; |
1235 | foreach ( $fieldValues as $fieldName => $fieldValue ) { |
1236 | $quotedFieldName = $db->addIdentifierQuotes( $fieldName ); |
1237 | $quotedFieldValues[$quotedFieldName] = $fieldValue; |
1238 | } |
1239 | // Calling tableName() here is necessary, for some reason, |
1240 | // to pass validation (and maybe even to work at all?) for |
1241 | // MW 1.41+. |
1242 | $sqlTableName = $db->tableName( $tableName ); |
1243 | $db->insert( $sqlTableName, $quotedFieldValues ); |
1244 | } |
1245 | |
1246 | /** |
1247 | * @param LinkRenderer $linkRenderer |
1248 | * @param LinkTarget|Title $title |
1249 | * @param string|null $msg Must already be HTML escaped |
1250 | * @param array $attrs link attributes |
1251 | * @param array $params query parameters |
1252 | * |
1253 | * @return string|null HTML link |
1254 | */ |
1255 | public static function makeLink( $linkRenderer, $title, $msg = null, $attrs = [], $params = [] ) { |
1256 | global $wgTitle; |
1257 | |
1258 | if ( $title === null ) { |
1259 | return null; |
1260 | } elseif ( $wgTitle !== null && $title->equals( $wgTitle ) ) { |
1261 | // Display bolded text instead of a link. |
1262 | return Linker::makeSelfLinkObj( $title, $msg ); |
1263 | } else { |
1264 | $html = ( $msg == null ) ? null : new HtmlArmor( $msg ); |
1265 | return $linkRenderer->makeLink( $title, $html, $attrs, $params ); |
1266 | } |
1267 | } |
1268 | |
1269 | public static function getSpecialPage( $pageName ) { |
1270 | return MediaWikiServices::getInstance()->getSpecialPageFactory() |
1271 | ->getPage( $pageName ); |
1272 | } |
1273 | |
1274 | /** |
1275 | * Get the wiki's content language. |
1276 | * @since 2.6 |
1277 | * @return Language |
1278 | */ |
1279 | public static function getContentLang() { |
1280 | return MediaWikiServices::getInstance()->getContentLanguage(); |
1281 | } |
1282 | |
1283 | public static function logTableAction( $actionName, $tableName, User $user ) { |
1284 | $log = new LogPage( 'cargo' ); |
1285 | $ctPage = self::getSpecialPage( 'CargoTables' ); |
1286 | $ctTitle = $ctPage->getPageTitle(); |
1287 | if ( $actionName == 'deletetable' ) { |
1288 | $logParams = [ $tableName ]; |
1289 | } else { |
1290 | $ctURL = $ctTitle->getFullURL(); |
1291 | $tableURL = "$ctURL/$tableName"; |
1292 | $tableLink = Html::element( |
1293 | 'a', |
1294 | [ 'href' => $tableURL ], |
1295 | $tableName |
1296 | ); |
1297 | $logParams = [ $tableLink ]; |
1298 | } |
1299 | // Every log entry requires an associated title; Cargo table |
1300 | // actions don't involve an actual page, so we just use |
1301 | // Special:CargoTables as the title. |
1302 | $log->addEntry( $actionName, $ctTitle, '', $logParams, $user ); |
1303 | } |
1304 | |
1305 | public static function validateHierarchyStructure( $hierarchyStructure ) { |
1306 | $hierarchyNodesArray = explode( "\n", $hierarchyStructure ); |
1307 | $matches = []; |
1308 | preg_match( '/^([*]*)[^*]*/i', $hierarchyNodesArray[0], $matches ); |
1309 | if ( strlen( $matches[1] ) != 1 ) { |
1310 | throw new MWException( "Error: First entry of hierarchy values should start with exact one '*', the entry \"" . |
1311 | $hierarchyNodesArray[0] . "\" has " . strlen( $matches[1] ) . " '*'" ); |
1312 | } |
1313 | $level = 0; |
1314 | foreach ( $hierarchyNodesArray as $node ) { |
1315 | if ( !preg_match( '/^([*]*)( *)(.*)/i', $node, $matches ) ) { |
1316 | throw new MWException( "Error: The \"" . $node . "\" entry of hierarchy values does not follow syntax. " . |
1317 | "The entry should be of the form : * entry" ); |
1318 | } |
1319 | if ( strlen( $matches[1] ) < 1 ) { |
1320 | throw new MWException( "Error: Each entry of hierarchy values should start with atleast one '*', the entry \"" . |
1321 | $node . "\" has " . strlen( $matches[1] ) . " '*'" ); |
1322 | } |
1323 | if ( strlen( $matches[1] ) - $level > 1 ) { |
1324 | throw new MWException( "Error: Level or count of '*' in hierarchy values should be increased only by count of 1, the entry \"" . |
1325 | $node . "\" should have " . ( $level + 1 ) . " or less '*'" ); |
1326 | } |
1327 | $level = strlen( $matches[1] ); |
1328 | if ( strlen( $matches[3] ) == 0 ) { |
1329 | throw new MWException( "Error: The entry of hierarchy values cannot be empty." ); |
1330 | } |
1331 | } |
1332 | } |
1333 | |
1334 | public static function validateFieldDescriptionString( $fieldDescriptionStr ) { |
1335 | $hasParameterFormat = preg_match( '/^([^(]*)\s*\((.*)\)$/s', $fieldDescriptionStr, $matches ); |
1336 | |
1337 | if ( !$hasParameterFormat ) { |
1338 | if ( self::stringContainsParentheses( $fieldDescriptionStr ) ) { |
1339 | throw new MWException( 'Invalid field description - Parentheses do not match: "' . $fieldDescriptionStr . '"' ); |
1340 | } |
1341 | } else { |
1342 | if ( count( $matches ) == 3 ) { |
1343 | $extraParamsString = $matches[2]; |
1344 | $extraParams = explode( ';', $extraParamsString ); |
1345 | |
1346 | foreach ( $extraParams as $extraParam ) { |
1347 | $extraParamParts = explode( '=', $extraParam, 2 ); |
1348 | $paramKey = strtolower( trim( $extraParamParts[0] ) ); |
1349 | $paramValue = isset( $extraParamParts[1] ) ? trim( $extraParamParts[1] ) : ''; |
1350 | |
1351 | if ( self::stringContainsParentheses( $paramKey ) ) { |
1352 | throw new MWException( 'Invalid field description - Parameter name "' . $paramKey . '" must not include parentheses: "' . $fieldDescriptionStr . '"' ); |
1353 | } |
1354 | |
1355 | if ( $paramKey == 'allowed values' ) { |
1356 | continue; |
1357 | } |
1358 | |
1359 | if ( self::stringContainsParentheses( $paramValue ) ) { |
1360 | throw new MWException( 'Invalid field description - Parameter value "' . $paramValue . '" must not include parentheses: "' . $fieldDescriptionStr . '"' ); |
1361 | } |
1362 | } |
1363 | } |
1364 | } |
1365 | } |
1366 | |
1367 | /** |
1368 | * A helper function, because Title::getTemplateLinksTo() is broken in |
1369 | * MW 1.37. |
1370 | */ |
1371 | public static function getTemplateLinksTo( $templateTitle, $options = [] ) { |
1372 | if ( class_exists( 'MediaWiki\CommentFormatter\CommentBatch' ) ) { |
1373 | // MW 1.38+ - just use the standard function again. |
1374 | return $templateTitle->getTemplateLinksTo( $options ); |
1375 | } |
1376 | |
1377 | $dbr = self::getMainDBForRead(); |
1378 | |
1379 | $selectFields = LinkCache::getSelectFields(); |
1380 | $selectFields[] = 'page_namespace'; |
1381 | $selectFields[] = 'page_title'; |
1382 | |
1383 | $res = $dbr->select( |
1384 | [ 'page', 'templatelinks' ], |
1385 | $selectFields, |
1386 | [ |
1387 | "tl_from=page_id", |
1388 | "tl_namespace" => NS_TEMPLATE, |
1389 | "tl_title" => $templateTitle->getDBkey() |
1390 | ], |
1391 | __METHOD__, |
1392 | $options |
1393 | ); |
1394 | |
1395 | $retVal = []; |
1396 | if ( $res->numRows() ) { |
1397 | $linkCache = MediaWikiServices::getInstance()->getLinkCache(); |
1398 | foreach ( $res as $row ) { |
1399 | $titleObj = Title::makeTitle( $row->page_namespace, $row->page_title ); |
1400 | if ( $titleObj ) { |
1401 | $linkCache->addGoodLinkObjFromRow( $titleObj, $row ); |
1402 | $retVal[] = $titleObj; |
1403 | } |
1404 | } |
1405 | } |
1406 | return $retVal; |
1407 | } |
1408 | |
1409 | public static function setParserOutputPageProperty( $parserOutput, $property, $value ) { |
1410 | if ( method_exists( $parserOutput, 'setPageProperty' ) ) { |
1411 | // MW 1.38 |
1412 | $parserOutput->setPageProperty( $property, $value ); |
1413 | } else { |
1414 | $parserOutput->setProperty( $property, $value ); |
1415 | } |
1416 | } |
1417 | |
1418 | public static function globalFields() { |
1419 | return [ |
1420 | '_pageID' => [ 'type' => 'Integer', 'isList' => false ], |
1421 | '_pageName' => [ 'type' => 'Page', 'isList' => false ], |
1422 | '_pageTitle' => [ 'type' => 'String', 'isList' => false ], |
1423 | '_pageNamespace' => [ 'type' => 'Integer', 'isList' => false ], |
1424 | ]; |
1425 | } |
1426 | |
1427 | public static function addGlobalFieldsToSchema( $schema ) { |
1428 | foreach ( self::globalFields() as $field => $fieldInfo ) { |
1429 | $fieldDesc = new CargoFieldDescription(); |
1430 | $fieldDesc->mType = $fieldInfo["type"]; |
1431 | $fieldDesc->mIsList = $fieldInfo["isList"]; |
1432 | if ( $fieldInfo["isList"] ) { |
1433 | $fieldDesc->setDelimiter( '|' ); |
1434 | } |
1435 | $schema->addField( $field, $fieldDesc ); |
1436 | } |
1437 | } |
1438 | |
1439 | public static function stringContainsParentheses( $str ) { |
1440 | return substr_count( $str, ')' ) > 0 || substr_count( $str, '(' ) > 0; |
1441 | } |
1442 | |
1443 | public static function makeWikiPage( $title ) { |
1444 | return MediaWikiServices::getInstance()->getWikiPageFactory()->newFromTitle( $title ); |
1445 | } |
1446 | } |