Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
50.95% covered (warning)
50.95%
134 / 263
0.00% covered (danger)
0.00%
0 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
CargoStore
50.95% covered (warning)
50.95%
134 / 263
0.00% covered (danger)
0.00%
0 / 5
980.18
0.00% covered (danger)
0.00%
0 / 1
 run
60.76% covered (warning)
60.76%
48 / 79
0.00% covered (danger)
0.00%
0 / 1
54.96
 blankOrRejectBadData
32.00% covered (danger)
32.00%
8 / 25
0.00% covered (danger)
0.00%
0 / 1
96.49
 getDateValueAndPrecision
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
132
 storeAllData
55.65% covered (warning)
55.65%
64 / 115
0.00% covered (danger)
0.00%
0 / 1
96.38
 doesRowAlreadyExist
70.00% covered (warning)
70.00%
14 / 20
0.00% covered (danger)
0.00%
0 / 1
11.19
1<?php
2/**
3 * Class for the #cargo_store function.
4 *
5 * @author Yaron Koren
6 * @ingroup Cargo
7 */
8
9use MediaWiki\MediaWikiServices;
10
11class CargoStore {
12
13    public static $settings = [];
14
15    public const DATE_AND_TIME = 0;
16    public const DATE_ONLY = 1;
17    public const MONTH_ONLY = 2;
18    public const YEAR_ONLY = 3;
19
20    // This can be removed when support for Cargo < 3.0 is dropped.
21    public const PARAMS_OPTIONAL = true;
22
23    /**
24     * Handles the #cargo_store parser function - saves data for one
25     * template call.
26     *
27     * @param Parser $parser
28     * @throws MWException
29     */
30    public static function run( $parser, $frame, $args ) {
31        // Get page-related information early on, so we can exit
32        // quickly if there's a problem.
33        $title = $parser->getTitle();
34        $pageID = $title->getArticleID();
35        if ( $pageID <= 0 ) {
36            // This will most likely happen if the title is a
37            // "special" page.
38            wfDebugLog( 'cargo', "CargoStore::run() - skipping; not called from a wiki page.\n" );
39            return;
40        }
41
42        $params = [];
43        foreach ( $args as $arg ) {
44            $params[] = trim( $frame->expand( $arg ) );
45        }
46
47        $tableName = null;
48        $tableFieldValues = [];
49
50        foreach ( $params as $param ) {
51            $parts = explode( '=', $param, 2 );
52
53            if ( count( $parts ) != 2 ) {
54                continue;
55            }
56            $key = trim( $parts[0] );
57            $value = trim( $parts[1] );
58            if ( $key == '_table' ) {
59                $tableName = $value;
60            } else {
61                $fieldName = $key;
62                // Since we don't know whether any empty
63                // value is meant to be blank or null, let's
64                // go with null.
65                if ( $value == '' ) {
66                    $value = null;
67                }
68                $fieldValue = $value;
69                $tableFieldValues[$fieldName] = $fieldValue;
70            }
71        }
72
73        if ( $tableName == '' ) {
74            $templateTitle = $frame->title;
75            list( $tableName, $isDeclared ) = CargoUtils::getTableNameForTemplate( $templateTitle );
76        }
77
78        if ( $tableName == '' ) {
79            return;
80        }
81
82        try {
83            $tableSchemas = CargoUtils::getTableSchemas( [ $tableName ] );
84        } catch ( MWException $e ) {
85            // Most likely, this table was never created - just exit.
86            return;
87        }
88
89        $fieldDescriptions = $tableSchemas[$tableName]->mFieldDescriptions;
90        $fieldNames = array_keys( $fieldDescriptions );
91
92        if ( $GLOBALS["wgCargoStoreUseTemplateArgsFallback"] ) {
93            // Go through all the fields for this table, setting any that
94            // were not explicitly set in the #cargo_store call.
95            foreach ( $fieldNames as $fieldName ) {
96                // Skip it if it's already being handled.
97                if ( array_key_exists( $fieldName, $tableFieldValues ) ) {
98                    continue;
99                }
100                // Look for a template parameter with the same name
101                // as this field, both with underscores and with spaces.
102                $curFieldValue = $frame->getArgument( $fieldName );
103
104                if ( $curFieldValue === false ) {
105                    $unescapedFieldName = str_replace( '_', ' ', $fieldName );
106                    $curFieldValue = $frame->getArgument( $unescapedFieldName );
107                }
108
109                // We don't want to unintentionally add false values in wrongly typed-fields
110                // in case strict mode is being used
111                if ( $curFieldValue !== false ) {
112                    $tableFieldValues[$fieldName] = $curFieldValue;
113                }
114            }
115        }
116
117        $origTableName = $tableName;
118
119        // Always store data in the replacement table if it exists.
120        $cdb = CargoUtils::getDB();
121        $cdb->begin();
122        if ( $cdb->tableExists( $tableName . '__NEXT' ) ) {
123            $tableName .= '__NEXT';
124        }
125
126        // Get the declaration of the table.
127        $dbw = wfGetDB( DB_MASTER );
128        $res = $dbw->select( 'cargo_tables', 'table_schema', [ 'main_table' => $tableName ] );
129        $row = $res->fetchRow();
130        if ( $row == '' ) {
131            // This table probably has not been created yet -
132            // just exit silently.
133            wfDebugLog( 'cargo', "CargoStore::run() - skipping; Cargo table ($tableName) does not exist.\n" );
134            $cdb->rollback();
135            return;
136        }
137        $tableSchema = CargoTableSchema::newFromDBString( $row['table_schema'] );
138
139        $errors = self::blankOrRejectBadData( $cdb, $title, $tableName, $tableFieldValues, $tableSchema );
140        $cdb->commit();
141
142        if ( $errors ) {
143            $parserOutput = $parser->getOutput();
144            CargoUtils::setParserOutputPageProperty( $parserOutput, 'CargoStorageError', $errors );
145            wfDebugLog( 'cargo', "CargoStore::run() - skipping; storage error encountered.\n" );
146            return;
147        }
148
149        // This function does actual DB modifications - so only proceed
150        // if this is called via either a page save or a "recreate
151        // data" action for a template that this page calls.
152        if ( count( self::$settings ) == 0 ) {
153            wfDebugLog( 'cargo', "CargoStore::run() - skipping; no settings defined.\n" );
154            return;
155        } elseif ( !array_key_exists( 'origin', self::$settings ) ) {
156            wfDebugLog( 'cargo', "CargoStore::run() - skipping; no origin defined.\n" );
157            return;
158        }
159
160        if ( self::$settings['origin'] == 'template' ) {
161            // It came from a template "recreate data" action -
162            // make sure it passes various criteria.
163            if ( self::$settings['dbTableName'] != $origTableName ) {
164                wfDebugLog( 'cargo', "CargoStore::run() - skipping; dbTableName not set.\n" );
165                return;
166            }
167        }
168
169        self::storeAllData( $title, $tableName, $tableFieldValues, $tableSchema );
170
171        // Finally, add a record of this to the cargo_pages table, if
172        // necessary.
173        $dbw = wfGetDB( DB_MASTER );
174        $res = $dbw->select( 'cargo_pages', 'page_id',
175            [ 'table_name' => $tableName, 'page_id' => $pageID ] );
176        if ( !$res->fetchRow() ) {
177            $dbw->insert( 'cargo_pages', [ 'table_name' => $tableName, 'page_id' => $pageID ] );
178        }
179    }
180
181    /**
182     * Deal with data that is considered invalid, for one reason or
183     * another. For the most part we simply ignore the data (if it's an
184     * invalid field) or blank it (if it's an invalid value), but if it's
185     * a mandatory value, we have no choice but to reject the whole row.
186     */
187    public static function blankOrRejectBadData( $cdb, $title, $tableName, &$tableFieldValues, $tableSchema ) {
188        foreach ( $tableFieldValues as $fieldName => $fieldValue ) {
189            if ( !array_key_exists( $fieldName, $tableSchema->mFieldDescriptions ) ) {
190                unset( $tableFieldValues[$fieldName] );
191            }
192        }
193
194        foreach ( $tableSchema->mFieldDescriptions as $fieldName => $fieldDescription ) {
195            if ( !array_key_exists( $fieldName, $tableFieldValues ) ) {
196                continue;
197            }
198            $fieldValue = $tableFieldValues[$fieldName];
199            if ( $fieldDescription->mIsMandatory && $fieldValue == '' ) {
200                return "Mandatory field, \"$fieldName\", cannot have a blank value.";
201            }
202            if ( $fieldDescription->mIsUnique && $fieldValue != '' ) {
203                $res = $cdb->select( $tableName, 'COUNT(*)', [ $fieldName => $fieldValue ] );
204                $row = $res->fetchRow();
205                $numExistingValues = $row['COUNT(*)'];
206                if ( $numExistingValues == 1 ) {
207                    $rowAlreadyExists = self::doesRowAlreadyExist( $cdb, $title, $tableName, $tableFieldValues, $tableSchema );
208                    if ( $rowAlreadyExists ) {
209                        $numExistingValues = 0;
210                    }
211                }
212                if ( $numExistingValues > 0 ) {
213                    if ( $fieldDescription->mIsMandatory ) {
214                        return "Cannot store mandatory field \"$fieldName\" as it contains a duplicate value.";
215                    }
216                    $tableFieldValues[$fieldName] = null;
217                }
218            }
219            if ( $fieldDescription->mRegex != null && !preg_match( '/^' . $fieldDescription->mRegex . '$/', $fieldValue ) ) {
220                if ( $fieldDescription->mIsMandatory ) {
221                    return "Cannot store mandatory field \"$fieldName\" as the value does not match the field's regex constraint.";
222                }
223                $tableFieldValues[$fieldName] = null;
224            }
225        }
226    }
227
228    public static function getDateValueAndPrecision( $dateStr, $fieldType ) {
229        $precision = null;
230
231        // Special handling if it's just a year. If it's a number and
232        // less than 8 digits, assume it's a year (hey, it could be a
233        // very large BC year). If it's 8 digits, it's probably a full
234        // date in the form YYYYMMDD.
235        if ( ctype_digit( $dateStr ) && strlen( $dateStr ) < 8 ) {
236            // Add a fake date - it will get ignored later.
237            return [ "$dateStr-01-01", self::YEAR_ONLY ];
238        }
239
240        // Determine if there's a month but no day. There's no ideal
241        // way to do this, so: we'll just look for the total number of
242        // spaces, slashes and dashes, and if there's exactly one
243        // altogether, we'll guess that it's a month only.
244        $numSpecialChars = substr_count( $dateStr, ' ' ) +
245            substr_count( $dateStr, '/' ) + substr_count( $dateStr, '-' );
246        if ( $numSpecialChars == 1 ) {
247            // No need to add anything - PHP will set it to the
248            // first of the month.
249            $precision = self::MONTH_ONLY;
250        } else {
251            // We have at least a full date.
252            if ( $fieldType == 'Date' ) {
253                $precision = self::DATE_ONLY;
254            }
255        }
256
257        $seconds = strtotime( $dateStr );
258        if ( $seconds === false ) {
259            return [ null, null ];
260        }
261        // If the precision has already been set, then we know it
262        // doesn't include a time value - we can set the value already.
263        if ( $precision != null ) {
264            // Put into YYYY-MM-DD format.
265            return [ date( 'Y-m-d', $seconds ), $precision ];
266        }
267
268        // It's a Datetime field, which may or may not have a time -
269        // check for that now.
270        $datePortion = date( 'Y-m-d', $seconds );
271        $timePortion = date( 'G:i:s', $seconds );
272        // If it's not right at midnight, there's definitely a time
273        // there.
274        $precision = self::DATE_AND_TIME;
275        if ( $timePortion !== '0:00:00' ) {
276            return [ $datePortion . ' ' . $timePortion, $precision ];
277        }
278
279        // It's midnight, so chances are good that there was no time
280        // specified, but how do we know for sure?
281        // Slight @HACK - look for either "00" or "AM" (or "am") in the
282        // original date string. If neither one is there, there's
283        // probably no time.
284        if ( strpos( $dateStr, '00' ) === false &&
285            strpos( $dateStr, 'AM' ) === false &&
286            strpos( $dateStr, 'am' ) === false ) {
287            $precision = self::DATE_ONLY;
288        }
289        // Either way, we just need the date portion.
290        return [ $datePortion, $precision ];
291    }
292
293    public static function storeAllData( $title, $tableName, $tableFieldValues, $tableSchema ) {
294        $pageID = $title->getArticleID();
295        $pageName = $title->getPrefixedText();
296        $pageTitle = $title->getText();
297        $pageNamespace = $title->getNamespace();
298
299        foreach ( $tableSchema->mFieldDescriptions as $fieldName => $fieldDescription ) {
300            // If it's null or not set, skip this value.
301            if ( !array_key_exists( $fieldName, $tableFieldValues ) ) {
302                continue;
303            }
304            $curValue = $tableFieldValues[$fieldName];
305            if ( $curValue === null ) {
306                continue;
307            }
308
309            $valueArray = $fieldDescription->prepareAndValidateValue( $curValue );
310            $tableFieldValues[$fieldName] = $valueArray['value'];
311            if ( array_key_exists( 'precision', $valueArray ) ) {
312                $tableFieldValues[$fieldName . '__precision'] = $valueArray['precision'];
313            }
314        }
315
316        // Add the "metadata" field values.
317        $tableFieldValues['_pageName'] = $pageName;
318        $tableFieldValues['_pageTitle'] = $pageTitle;
319        $tableFieldValues['_pageNamespace'] = $pageNamespace;
320        $tableFieldValues['_pageID'] = $pageID;
321
322        // Allow other hooks to modify the values.
323        MediaWikiServices::getInstance()->getHookContainer()->run( 'CargoBeforeStoreData', [ $title, $tableName, &$tableSchema, &$tableFieldValues ] );
324
325        $cdb = CargoUtils::getDB();
326
327        // Somewhat of a @HACK - recreating a Cargo table from the web
328        // interface can lead to duplicate rows, due to the use of jobs.
329        // So before we store this data, check if a row with this
330        // exact set of data is already in the database. If it is, just
331        // ignore this #cargo_store call.
332        // This is not ideal, because there can be valid duplicate
333        // data - a page can have multiple calls to the same template,
334        // with identical data, for various reasons. However, that's
335        // a very rare case, while unwanted code duplication is
336        // unfortunately a common case. So until there's a real
337        // solution, this workaround will be helpful.
338        $rowAlreadyExists = self::doesRowAlreadyExist( $cdb, $title, $tableName, $tableFieldValues, $tableSchema );
339        if ( $rowAlreadyExists ) {
340            return;
341        }
342
343        // The _position field was only added to list tables in Cargo
344        // 2.1, which means that any list table last created or
345        // re-created before then will not have that field. How to know
346        // whether to populate that field? We go to the first list
347        // table for this main table (there may be more than one), query
348        // that field, and see whether it throws an exception. (We'll
349        // assume that either all the list tables for this main table
350        // have a _position field, or none do.)
351        $hasPositionField = true;
352        foreach ( $tableSchema->mFieldDescriptions as $fieldName => $fieldDescription ) {
353            if ( $fieldDescription->mIsList ) {
354                $listFieldTableName = $tableName . '__' . $fieldName;
355                try {
356                    $cdb->select( $listFieldTableName, 'COUNT(' .
357                        $cdb->addIdentifierQuotes( '_position' ) . ')' );
358                } catch ( Exception $e ) {
359                    $hasPositionField = false;
360                }
361                break;
362            }
363        }
364
365        // We put the retrieval of the row ID, and the saving of the new row, into a
366        // single DB transaction, to avoid "collisions".
367        $cdb->begin();
368
369        $maxID = $cdb->selectField( $tableName,
370            'MAX(' . $cdb->addIdentifierQuotes( '_ID' ) . ')' );
371        $curRowID = $maxID + 1;
372        $tableFieldValues['_ID'] = $curRowID;
373        $fieldTableFieldValues = [];
374
375        // For each field that holds a list of values, also add its
376        // values to its own table; and rename the actual field.
377        foreach ( $tableSchema->mFieldDescriptions as $fieldName => $fieldDescription ) {
378            if ( !array_key_exists( $fieldName, $tableFieldValues ) ) {
379                continue;
380            }
381            $fieldType = $fieldDescription->mType;
382            if ( $fieldDescription->mIsList ) {
383                $fieldTableName = $tableName . '__' . $fieldName;
384                $delimiter = $fieldDescription->getDelimiter();
385                $individualValues = explode( $delimiter, $tableFieldValues[$fieldName] );
386                $valueNum = 1;
387                foreach ( $individualValues as $individualValue ) {
388                    $individualValue = trim( $individualValue );
389                    // Ignore blank values.
390                    if ( $individualValue == '' ) {
391                        continue;
392                    }
393                    $fieldValues = [
394                        '_rowID' => $curRowID,
395                        '_value' => $individualValue
396                    ];
397                    if ( $hasPositionField ) {
398                        $fieldValues['_position'] = $valueNum++;
399                    }
400                    if ( $fieldDescription->isDateOrDatetime() ) {
401                        list( $dateValue, $precision ) = self::getDateValueAndPrecision( $individualValue, $fieldType );
402                        $fieldValues['_value'] = $dateValue;
403                        $fieldValues['_value__precision'] = $precision;
404                    }
405                    // For coordinates, there are two more
406                    // fields, for latitude and longitude.
407                    if ( $fieldType == 'Coordinates' ) {
408                        try {
409                            list( $latitude, $longitude ) = CargoUtils::parseCoordinatesString( $individualValue );
410                        } catch ( MWException $e ) {
411                            continue;
412                        }
413                        $fieldValues['_lat'] = $latitude;
414                        $fieldValues['_lon'] = $longitude;
415                    }
416                    // We could store these values in the DB
417                    // now, but we'll do it later, to keep
418                    // the transaction as short as possible.
419                    $fieldTableFieldValues[] = [ $fieldTableName, $fieldValues ];
420                }
421
422                // Now rename the field.
423                $tableFieldValues[$fieldName . '__full'] = $tableFieldValues[$fieldName];
424                unset( $tableFieldValues[$fieldName] );
425            } elseif ( $fieldType == 'Coordinates' ) {
426                try {
427                    list( $latitude, $longitude ) = CargoUtils::parseCoordinatesString( $tableFieldValues[$fieldName] );
428                } catch ( MWException $e ) {
429                    unset( $tableFieldValues[$fieldName] );
430                    continue;
431                }
432                // Rename the field.
433                $tableFieldValues[$fieldName . '__full'] = $tableFieldValues[$fieldName];
434                unset( $tableFieldValues[$fieldName] );
435                $tableFieldValues[$fieldName . '__lat'] = $latitude;
436                $tableFieldValues[$fieldName . '__lon'] = $longitude;
437            }
438        }
439
440        // Insert the current data into the main table.
441        CargoUtils::escapedInsert( $cdb, $tableName, $tableFieldValues );
442
443        // End transaction and apply DB changes.
444        $cdb->commit();
445
446        // Now, store the data for all the "field tables".
447        foreach ( $fieldTableFieldValues as $tableNameAndValues ) {
448            list( $fieldTableName, $fieldValues ) = $tableNameAndValues;
449            CargoUtils::escapedInsert( $cdb, $fieldTableName, $fieldValues );
450        }
451
452        // Also insert the names of any "attached" files into the
453        // "files" helper table.
454        $fileTableName = $tableName . '___files';
455        foreach ( $tableSchema->mFieldDescriptions as $fieldName => $fieldDescription ) {
456            $fieldType = $fieldDescription->mType;
457            // Only handle this field if it's of type File, and if it exists in the table records.
458            if ( $fieldType != 'File' || !array_key_exists( $fieldName, $tableFieldValues ) ) {
459                continue;
460            }
461            if ( $fieldDescription->mIsList ) {
462                $delimiter = $fieldDescription->getDelimiter();
463                $individualValues = explode( $delimiter, $tableFieldValues[$fieldName . '__full'] );
464                foreach ( $individualValues as $individualValue ) {
465                    $individualValue = trim( $individualValue );
466                    // Ignore blank values.
467                    if ( $individualValue == '' ) {
468                        continue;
469                    }
470                    $fileName = CargoUtils::removeNamespaceFromFileName( $individualValue );
471                    $fieldValues = [
472                        '_pageName' => $pageName,
473                        '_pageID' => $pageID,
474                        '_fieldName' => $fieldName,
475                        '_fileName' => $fileName
476                    ];
477                    CargoUtils::escapedInsert( $cdb, $fileTableName, $fieldValues );
478                }
479            } else {
480                $fullFileName = $tableFieldValues[$fieldName];
481                if ( $fullFileName == '' ) {
482                    continue;
483                }
484                $fileName = CargoUtils::removeNamespaceFromFileName( $fullFileName );
485                $fieldValues = [
486                    '_pageName' => $pageName,
487                    '_pageID' => $pageID,
488                    '_fieldName' => $fieldName,
489                    '_fileName' => $fileName
490                ];
491                CargoUtils::escapedInsert( $cdb, $fileTableName, $fieldValues );
492            }
493        }
494    }
495
496    /**
497     * Determines whether a row with the specified set of values already
498     * exists in the specified Cargo table.
499     */
500    public static function doesRowAlreadyExist( $cdb, $title, $tableName, $tableFieldValues, $tableSchema ) {
501        $pageID = $title->getArticleID();
502        $tableFieldValuesForCheck = [ $cdb->addIdentifierQuotes( '_pageID' ) => $pageID ];
503        foreach ( $tableSchema->mFieldDescriptions as $fieldName => $fieldDescription ) {
504            if ( !array_key_exists( $fieldName, $tableFieldValues ) ) {
505                continue;
506            }
507            if ( $fieldDescription->mIsList || $fieldDescription->mType == 'Coordinates' ) {
508                $quotedFieldName = $cdb->addIdentifierQuotes( $fieldName . '__full' );
509            } else {
510                $quotedFieldName = $cdb->addIdentifierQuotes( $fieldName );
511            }
512            $fieldValue = $tableFieldValues[$fieldName];
513
514            if ( in_array( $fieldDescription->mType, [ 'Text', 'Wikitext', 'Searchtext' ] ) ) {
515                // @HACK - for some reason, there are times
516                // when, for long values, the check only works
517                // if there's some kind of limit in place.
518                // Rather than delve into that, we'll just
519                // make sure to only check a (relatively large)
520                // substring - which should be good enough.
521                $fieldSize = 1000;
522            } else {
523                $fieldSize = $fieldDescription->getFieldSize();
524            }
525
526            if ( $fieldValue === '' ) {
527                // Needed for correct SQL handling of blank values, for some reason.
528                $fieldValue = null;
529            } elseif ( $fieldSize != null && strlen( $fieldValue ) > $fieldSize ) {
530                // In theory, this SUBSTR() call is not needed,
531                // since the value stored in the DB won't be
532                // greater than this size. But that's not
533                // always true - there's the hack mentioned
534                // above, plus some other cases.
535                $quotedFieldName = "SUBSTR($quotedFieldName, 1, $fieldSize)";
536                $fieldValue = mb_substr( $fieldValue, 0, $fieldSize );
537            }
538
539            $tableFieldValuesForCheck[$quotedFieldName] = $fieldValue;
540        }
541        $count = $cdb->selectRowCount( $tableName, '*', $tableFieldValuesForCheck, __METHOD__ );
542        return ( $count > 0 );
543    }
544}