Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
56.90% covered (warning)
56.90%
33 / 58
38.46% covered (danger)
38.46%
5 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
UnionQueryBuilder
56.90% covered (warning)
56.90%
33 / 58
38.46% covered (danger)
38.46%
5 / 13
107.96
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
 add
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 all
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 limit
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 offset
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 orderBy
45.45% covered (danger)
45.45%
5 / 11
0.00% covered (danger)
0.00%
0 / 1
9.06
 mergeOption
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 caller
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 fetchResultSet
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
5
 fetchField
37.50% covered (danger)
37.50%
3 / 8
0.00% covered (danger)
0.00%
0 / 1
5.20
 fetchFieldValues
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 fetchRow
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 getSQL
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3namespace Wikimedia\Rdbms;
4
5use Wikimedia\Rdbms\Platform\ISQLPlatform;
6
7/**
8 * A query builder for UNION queries takes SelectQueryBuilder objects
9 *
10 * Any particular query builder object should only be used for a single database query,
11 * and not be reused afterwards.
12 *
13 * @since 1.41
14 * @ingroup Database
15 */
16class UnionQueryBuilder {
17    /** sort the results in ascending order */
18    public const SORT_ASC = 'ASC';
19
20    /** sort the results in descending order */
21    public const SORT_DESC = 'DESC';
22
23    /**
24     * @var SelectQueryBuilder[]
25     */
26    private $sqbs = [];
27
28    private IDatabase $db;
29
30    /** @var bool */
31    private $all = IReadableDatabase::UNION_DISTINCT;
32
33    /** @var array */
34    private $options = [];
35
36    /**
37     * @var string The caller (function name) to be passed to IDatabase::query()
38     */
39    private $caller = __CLASS__;
40
41    /**
42     * To create a UnionQueryBuilder instance, use `$db->newUnionQueryBuilder()` instead.
43     */
44    public function __construct( IDatabase $db ) {
45        $this->db = $db;
46    }
47
48    /**
49     * Add a select query builder object to the list of union
50     *
51     * @return $this
52     */
53    public function add( SelectQueryBuilder $selectQueryBuilder ) {
54        $this->sqbs[] = $selectQueryBuilder;
55        return $this;
56    }
57
58    /**
59     * Enable UNION_ALL option, the default is UNION_DISTINCT
60     *
61     * @return $this
62     */
63    public function all() {
64        $this->all = $this->db::UNION_ALL;
65        return $this;
66    }
67
68    /**
69     * Set the query limit. Return at most this many rows. The rows are sorted
70     * and then the first rows are taken until the limit is reached. Limit
71     * is applied to a result set after offset.
72     *
73     * If the query builder already has a limit, the old limit will be discarded.
74     * This would be also ignored if the DB does not support limit in union queries.
75     *
76     * @param int $limit
77     * @return $this
78     */
79    public function limit( $limit ) {
80        if ( !$this->db->unionSupportsOrderAndLimit() ) {
81            return $this;
82        }
83        $this->options['LIMIT'] = $limit;
84        return $this;
85    }
86
87    /**
88     * Set the offset. Skip this many rows at the start of the result set. Offset
89     * with limit() can theoretically be used for paging through a result set,
90     * but this is discouraged for performance reasons.
91     *
92     * If the query builder already has an offset, the old offset will be discarded.
93     * This would be also ignored if the DB does not support offset in union queries.
94     *
95     * @param int $offset
96     * @return $this
97     */
98    public function offset( $offset ) {
99        if ( !$this->db->unionSupportsOrderAndLimit() ) {
100            return $this;
101        }
102        $this->options['OFFSET'] = $offset;
103        return $this;
104    }
105
106    /**
107     * Set the ORDER BY clause. If it has already been set, append the
108     * additional fields to it.
109     *
110     * This would be ignored if the DB does not support order by in union queries.
111     *
112     * @param string[]|string $fields The field or list of fields to order by.
113     * @param-taint $fields exec_sql
114     * @param string|null $direction self::SORT_ASC or self::SORT_DESC.
115     * If this is null then $fields is assumed to optionally contain ASC or DESC
116     * after each field name.
117     * @param-taint $direction exec_sql
118     * @return $this
119     */
120    public function orderBy( $fields, $direction = null ) {
121        if ( !$this->db->unionSupportsOrderAndLimit() ) {
122            return $this;
123        }
124        if ( $direction === null ) {
125            $this->mergeOption( 'ORDER BY', $fields );
126        } elseif ( is_array( $fields ) ) {
127            $fieldsWithDirection = [];
128            foreach ( $fields as $field ) {
129                $fieldsWithDirection[] = "$field $direction";
130            }
131            $this->mergeOption( 'ORDER BY', $fieldsWithDirection );
132        } else {
133            $this->mergeOption( 'ORDER BY', "$fields $direction" );
134        }
135        return $this;
136    }
137
138    /**
139     * Add a value to an option which may be not set or a string or array.
140     *
141     * @param string $name
142     * @param string|string[] $newArrayOrValue
143     */
144    private function mergeOption( $name, $newArrayOrValue ) {
145        $value = isset( $this->options[$name] )
146            ? (array)$this->options[$name] : [];
147        if ( is_array( $newArrayOrValue ) ) {
148            $value = array_merge( $value, $newArrayOrValue );
149        } else {
150            $value[] = $newArrayOrValue;
151        }
152        $this->options[$name] = $value;
153    }
154
155    /**
156     * Set the method name to be included in an SQL comment.
157     *
158     * @param string $fname
159     * @param-taint $fname exec_sql
160     * @return $this
161     */
162    public function caller( $fname ) {
163        $this->caller = $fname;
164        return $this;
165    }
166
167    /**
168     * Run the constructed UNION query and return all results.
169     *
170     * @return IResultWrapper
171     */
172    public function fetchResultSet() {
173        // @codeCoverageIgnoreStart
174        if ( defined( 'MW_PHPUNIT_TEST' ) && str_contains( $this->db->getSoftwareLink(), 'MySQL' ) ) {
175            // MySQL cannot open the same temporary table twice in the same query, and integration tests
176            // use temporary tables, so we need to emulate the UNION query (T412067).
177            $resultSets = array_map( static fn ( $qb ) => $qb->fetchResultSet(), $this->sqbs );
178            $res = [];
179            foreach ( $resultSets as $resultSet ) {
180                $res = array_merge( $res, iterator_to_array( $resultSet ) );
181            }
182            if ( $this->all === $this->db::UNION_DISTINCT ) {
183                $res = array_unique( $res );
184            }
185            return new FakeResultWrapper( $res );
186        }
187        // @codeCoverageIgnoreEnd
188        $query = new Query( $this->getSQL(), ISQLPlatform::QUERY_CHANGE_NONE, 'SELECT' );
189        return $this->db->query( $query, $this->caller );
190    }
191
192    /**
193     * Run the constructed UNION query, and return a single field extracted
194     * from the first result row. If there were no result rows, false is
195     * returned. This may only be called when only one field has been added to
196     * the constituent queries.
197     *
198     * @since 1.42
199     * @return mixed
200     * @return-taint tainted
201     */
202    public function fetchField() {
203        $this->limit( 1 );
204        foreach ( $this->fetchResultSet() as $row ) {
205            $row = (array)$row;
206            if ( count( $row ) !== 1 ) {
207                throw new \UnexpectedValueException(
208                    __METHOD__ . ' expects the query to have only one field' );
209            }
210            return $row[ array_key_first( $row ) ];
211        }
212        return false;
213    }
214
215    /**
216     * Run the constructed UNION query, and extract a single field from each
217     * result row, returning an array containing all the values. This may only
218     * be called when only one field has been added to the constituent queries.
219     *
220     * @since 1.42
221     * @return array
222     * @return-taint tainted
223     */
224    public function fetchFieldValues() {
225        $values = [];
226        foreach ( $this->fetchResultSet() as $row ) {
227            $row = (array)$row;
228            if ( count( $row ) !== 1 ) {
229                throw new \UnexpectedValueException(
230                    __METHOD__ . ' expects the query to have only one field' );
231            }
232            $values[] = $row[ array_key_first( $row ) ];
233        }
234        return $values;
235    }
236
237    /**
238     * Run the constructed UNION query, and return the first result row. If
239     * there were no results, return false.
240     *
241     * @since 1.42
242     * @return \stdClass|false
243     * @return-taint tainted
244     */
245    public function fetchRow() {
246        $this->limit( 1 );
247        foreach ( $this->fetchResultSet() as $row ) {
248            return $row;
249        }
250        return false;
251    }
252
253    /**
254     * Get the SQL query string which would be used by fetchResultSet().
255     *
256     * @since 1.42
257     * @return string
258     */
259    public function getSQL() {
260        $sqls = [];
261        foreach ( $this->sqbs as $sqb ) {
262            $sqls[] = $sqb->getSQL();
263        }
264        return $this->db->unionQueries( $sqls, $this->all, $this->options );
265    }
266
267}