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