Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
19.30% covered (danger)
19.30%
11 / 57
22.22% covered (danger)
22.22%
2 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
DbStorage
19.30% covered (danger)
19.30%
11 / 57
22.22% covered (danger)
22.22%
2 / 9
536.09
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 preprocessNestedSqlArray
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 preprocessSqlArray
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getFieldRegexFragment
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 validateOptions
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
240
 validate
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 calcUpdates
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
5.03
 useInsertIgnore
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 maybeSetInsertIgnore
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3namespace Flow\Data\Storage;
4
5use Flow\Data\ObjectManager;
6use Flow\Data\ObjectStorage;
7use Flow\DbFactory;
8use Flow\Exception\DataModelException;
9use Flow\Model\UUID;
10use Wikimedia\Rdbms\InsertQueryBuilder;
11
12/**
13 * Base class for all ObjectStorage implementers
14 * which use a database as the backing store.
15 *
16 * Includes some utility methods for database management and
17 * SQL security.
18 */
19abstract class DbStorage implements ObjectStorage {
20    /**
21     * @var DbFactory
22     */
23    protected $dbFactory;
24
25    /**
26     * The revision columns allowed to be updated
27     *
28     * @var string[]|true Allow of selective columns to allow, or true to allow
29     *   everything
30     */
31    protected $allowedUpdateColumns = true;
32
33    /**
34     * This is to prevent 'Update not allowed on xxx' error during moderation when
35     * * old cache is not purged and still holds obsolete deleted column
36     * * old cache is not purged and doesn't have the newly added column
37     *
38     * @var string[] Array of columns to ignore
39     */
40    protected $obsoleteUpdateColumns = [];
41
42    public function __construct( DbFactory $dbFactory ) {
43        $this->dbFactory = $dbFactory;
44    }
45
46    /**
47     * Runs preprocessSqlArray on each element of an array.
48     *
49     * @param array $outer The array to check
50     * @return array Preprocessed SQL array.
51     * @throws DataModelException
52     */
53    protected function preprocessNestedSqlArray( array $outer ) {
54        foreach ( $outer as $i => $data ) {
55            if ( !is_array( $data ) ) {
56                throw new DataModelException( "Unexpected non-array in nested SQL array" );
57            }
58            $outer[$i] = $this->preprocessSqlArray( $data );
59        }
60        return $outer;
61    }
62
63    /**
64     * Finds UUID objects and returns their database representation.
65     *
66     * @param array $data Query conditions for IDatabase::select
67     * @return array query conditions
68     */
69    protected function preprocessSqlArray( array $data ) {
70        $data = UUID::convertUUIDs( $data, 'binary' );
71
72        return $data;
73    }
74
75    /**
76     * Returns a regular expression fragment suitable for matching a valid
77     * SQL field name, and hopefully no injection attacks
78     * @return string Regular expression fragment
79     */
80    protected function getFieldRegexFragment() {
81        return '\s*[A-Za-z0-9\._]+\s*';
82    }
83
84    /**
85     * Internal security function to check an options array for
86     * SQL injection and other funkiness
87     * @todo Currently only supports LIMIT, OFFSET and ORDER BY
88     * @param array $options An options array passed to a query.
89     * @return bool
90     */
91    protected function validateOptions( $options ) {
92        static $validUnaryOptions = [
93            'UNIQUE',
94            'EXPLAIN',
95        ];
96
97        $fieldRegex = $this->getFieldRegexFragment();
98
99        foreach ( $options as $key => $value ) {
100            if ( is_numeric( $key ) && in_array( strtoupper( $value ), $validUnaryOptions ) ) {
101                continue;
102            } elseif ( is_numeric( $key ) ) {
103                wfDebug( __METHOD__ . ": Unrecognised unary operator $value\n" );
104                return false;
105            }
106
107            if ( $key === 'LIMIT' ) {
108                // LIMIT is one or two integers, separated by a comma.
109                if ( !preg_match( '/^\d+(,\d+)?$/', $value ) ) {
110                    wfDebug( __METHOD__ . ": Invalid LIMIT $value\n" );
111                    return false;
112                }
113            } elseif ( $key === 'ORDER BY' ) {
114                // ORDER BY is a list of field names with ASC / DESC afterwards
115                if ( is_string( $value ) ) {
116                    $value = explode( ',', $value );
117                }
118                $orderByRegex = "/^\s*$fieldRegex\s*(ASC|DESC)?\s*$/i";
119
120                foreach ( $value as $orderByField ) {
121                    if ( !preg_match( $orderByRegex, $orderByField ) ) {
122                        wfDebug( __METHOD__ . ": invalid ORDER BY field $orderByField\n" );
123                        return false;
124                    }
125                }
126            } elseif ( $key === 'OFFSET' ) {
127                // OFFSET is just an integer
128                if ( !is_numeric( $value ) ) {
129                    wfDebug( __METHOD__ . ": non-numeric offset $value\n" );
130                    return false;
131                }
132            } elseif ( $key === 'GROUP BY' ) {
133                if ( !preg_match( "/^{$fieldRegex}(,{$fieldRegex})+$/", $value ) ) {
134                    wfDebug( __METHOD__ . ": invalid GROUP BY field\n" );
135                }
136            } else {
137                wfDebug( __METHOD__ . ": Unknown option $key\n" );
138                return false;
139            }
140        }
141
142        // Everything passes
143        return true;
144    }
145
146    /**
147     * @inheritDoc
148     */
149    public function validate( array $row ) {
150        return true;
151    }
152
153    /**
154     * Calculates the DB updates to be performed to update data from $old to
155     * $new.
156     *
157     * @param array $old
158     * @param array $new
159     * @return array
160     * @throws DataModelException
161     */
162    public function calcUpdates( array $old, array $new ) {
163        $changeSet = ObjectManager::calcUpdatesWithoutValidation( $old, $new );
164
165        foreach ( $this->obsoleteUpdateColumns as $val ) {
166            // Need to use array_key_exists to check null value
167            if ( array_key_exists( $val, $changeSet ) ) {
168                unset( $changeSet[$val] );
169            }
170        }
171
172        if ( is_array( $this->allowedUpdateColumns ) ) {
173            $extra = array_diff( array_keys( $changeSet ), $this->allowedUpdateColumns );
174            if ( $extra ) {
175                throw new DataModelException( 'Update not allowed on: ' . implode( ', ', $extra ), 'process-data' );
176            }
177        }
178
179        return $changeSet;
180    }
181
182    /**
183     * Checks if the --insert-ignore flag is set.
184     *
185     * @return bool
186     */
187    public static function useInsertIgnore(): bool {
188        if ( MW_ENTRY_POINT !== 'cli' ) {
189            return false;
190        }
191        // Check if the command line script passed the --insert-ignore flag
192        // HACK: Read the global $argv as this method is too deep in the call stack to
193        // pass the option through properly.
194        global $argv;
195        return in_array( '--insert-ignore', $argv, true );
196    }
197
198    /**
199     * If running in CLI and the --insert-ignore flag is set, modify the query builder
200     * to ignore duplicate key insert errors.
201     *
202     * @param InsertQueryBuilder $queryBuilder
203     */
204    public static function maybeSetInsertIgnore( InsertQueryBuilder $queryBuilder ): void {
205        if ( self::useInsertIgnore() ) {
206            $queryBuilder->ignore();
207        }
208    }
209}