Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
freebsd
GitHub Repository: freebsd/phabricator
Path: blob/master/src/infrastructure/query/policy/PhabricatorCursorPagedPolicyAwareQuery.php
12242 views
1
<?php
2
3
/**
4
* A query class which uses cursor-based paging. This paging is much more
5
* performant than offset-based paging in the presence of policy filtering.
6
*
7
* @task cursors Query Cursors
8
* @task clauses Building Query Clauses
9
* @task appsearch Integration with ApplicationSearch
10
* @task customfield Integration with CustomField
11
* @task paging Paging
12
* @task order Result Ordering
13
* @task edgelogic Working with Edge Logic
14
* @task spaces Working with Spaces
15
*/
16
abstract class PhabricatorCursorPagedPolicyAwareQuery
17
extends PhabricatorPolicyAwareQuery {
18
19
private $externalCursorString;
20
private $internalCursorObject;
21
private $isQueryOrderReversed = false;
22
private $rawCursorRow;
23
24
private $applicationSearchConstraints = array();
25
private $internalPaging;
26
private $orderVector;
27
private $groupVector;
28
private $builtinOrder;
29
private $edgeLogicConstraints = array();
30
private $edgeLogicConstraintsAreValid = false;
31
private $spacePHIDs;
32
private $spaceIsArchived;
33
private $ngrams = array();
34
private $ferretEngine;
35
private $ferretTokens = array();
36
private $ferretTables = array();
37
private $ferretQuery;
38
private $ferretMetadata = array();
39
private $ngramEngine;
40
41
const FULLTEXT_RANK = '_ft_rank';
42
const FULLTEXT_MODIFIED = '_ft_epochModified';
43
const FULLTEXT_CREATED = '_ft_epochCreated';
44
45
/* -( Cursors )------------------------------------------------------------ */
46
47
protected function newExternalCursorStringForResult($object) {
48
if (!($object instanceof LiskDAO)) {
49
throw new Exception(
50
pht(
51
'Expected to be passed a result object of class "LiskDAO" in '.
52
'"newExternalCursorStringForResult()", actually passed "%s". '.
53
'Return storage objects from "loadPage()" or override '.
54
'"newExternalCursorStringForResult()".',
55
phutil_describe_type($object)));
56
}
57
58
return (string)$object->getID();
59
}
60
61
protected function newInternalCursorFromExternalCursor($cursor) {
62
$viewer = $this->getViewer();
63
64
$query = newv(get_class($this), array());
65
66
$query
67
->setParentQuery($this)
68
->setViewer($viewer);
69
70
// We're copying our order vector to the subquery so that the subquery
71
// knows it should generate any supplemental information required by the
72
// ordering.
73
74
// For example, Phriction documents may be ordered by title, but the title
75
// isn't a column in the "document" table: the query must JOIN the
76
// "content" table to perform the ordering. Passing the ordering to the
77
// subquery tells it that we need it to do that JOIN and attach relevant
78
// paging information to the internal cursor object.
79
80
// We only expect to load a single result, so the actual result order does
81
// not matter. We only want the internal cursor for that result to look
82
// like a cursor this parent query would generate.
83
$query->setOrderVector($this->getOrderVector());
84
85
$this->applyExternalCursorConstraintsToQuery($query, $cursor);
86
87
// If we have a Ferret fulltext query, copy it to the subquery so that we
88
// generate ranking columns appropriately, and compute the correct object
89
// ranking score for the current query.
90
if ($this->ferretEngine) {
91
$query->withFerretConstraint($this->ferretEngine, $this->ferretTokens);
92
}
93
94
// We're executing the subquery normally to make sure the viewer can
95
// actually see the object, and that it's a completely valid object which
96
// passes all filtering and policy checks. You aren't allowed to use an
97
// object you can't see as a cursor, since this can leak information.
98
$result = $query->executeOne();
99
if (!$result) {
100
$this->throwCursorException(
101
pht(
102
'Cursor "%s" does not identify a valid object in query "%s".',
103
$cursor,
104
get_class($this)));
105
}
106
107
// Now that we made sure the viewer can actually see the object the
108
// external cursor identifies, return the internal cursor the query
109
// generated as a side effect while loading the object.
110
return $query->getInternalCursorObject();
111
}
112
113
final protected function throwCursorException($message) {
114
throw new PhabricatorInvalidQueryCursorException($message);
115
}
116
117
protected function applyExternalCursorConstraintsToQuery(
118
PhabricatorCursorPagedPolicyAwareQuery $subquery,
119
$cursor) {
120
$subquery->withIDs(array($cursor));
121
}
122
123
protected function newPagingMapFromCursorObject(
124
PhabricatorQueryCursor $cursor,
125
array $keys) {
126
127
$object = $cursor->getObject();
128
129
return $this->newPagingMapFromPartialObject($object);
130
}
131
132
protected function newPagingMapFromPartialObject($object) {
133
return array(
134
'id' => (int)$object->getID(),
135
);
136
}
137
138
private function getExternalCursorStringForResult($object) {
139
$cursor = $this->newExternalCursorStringForResult($object);
140
141
if (!is_string($cursor)) {
142
throw new Exception(
143
pht(
144
'Expected "newExternalCursorStringForResult()" in class "%s" to '.
145
'return a string, but got "%s".',
146
get_class($this),
147
phutil_describe_type($cursor)));
148
}
149
150
return $cursor;
151
}
152
153
final protected function getExternalCursorString() {
154
return $this->externalCursorString;
155
}
156
157
private function setExternalCursorString($external_cursor) {
158
$this->externalCursorString = $external_cursor;
159
return $this;
160
}
161
162
final protected function getIsQueryOrderReversed() {
163
return $this->isQueryOrderReversed;
164
}
165
166
final protected function setIsQueryOrderReversed($is_reversed) {
167
$this->isQueryOrderReversed = $is_reversed;
168
return $this;
169
}
170
171
private function getInternalCursorObject() {
172
return $this->internalCursorObject;
173
}
174
175
private function setInternalCursorObject(
176
PhabricatorQueryCursor $cursor) {
177
$this->internalCursorObject = $cursor;
178
return $this;
179
}
180
181
private function getInternalCursorFromExternalCursor(
182
$cursor_string) {
183
184
$cursor_object = $this->newInternalCursorFromExternalCursor($cursor_string);
185
186
if (!($cursor_object instanceof PhabricatorQueryCursor)) {
187
throw new Exception(
188
pht(
189
'Expected "newInternalCursorFromExternalCursor()" to return an '.
190
'object of class "PhabricatorQueryCursor", but got "%s" (in '.
191
'class "%s").',
192
phutil_describe_type($cursor_object),
193
get_class($this)));
194
}
195
196
return $cursor_object;
197
}
198
199
private function getPagingMapFromCursorObject(
200
PhabricatorQueryCursor $cursor,
201
array $keys) {
202
203
$map = $this->newPagingMapFromCursorObject($cursor, $keys);
204
205
if (!is_array($map)) {
206
throw new Exception(
207
pht(
208
'Expected "newPagingMapFromCursorObject()" to return a map of '.
209
'paging values, but got "%s" (in class "%s").',
210
phutil_describe_type($map),
211
get_class($this)));
212
}
213
214
if ($this->supportsFerretEngine()) {
215
if ($this->hasFerretOrder()) {
216
$map += array(
217
'rank' =>
218
$cursor->getRawRowProperty(self::FULLTEXT_RANK),
219
'fulltext-modified' =>
220
$cursor->getRawRowProperty(self::FULLTEXT_MODIFIED),
221
'fulltext-created' =>
222
$cursor->getRawRowProperty(self::FULLTEXT_CREATED),
223
);
224
}
225
}
226
227
foreach ($keys as $key) {
228
if (!array_key_exists($key, $map)) {
229
throw new Exception(
230
pht(
231
'Map returned by "newPagingMapFromCursorObject()" in class "%s" '.
232
'omits required key "%s".',
233
get_class($this),
234
$key));
235
}
236
}
237
238
return $map;
239
}
240
241
final protected function nextPage(array $page) {
242
if (!$page) {
243
return;
244
}
245
246
$cursor = id(new PhabricatorQueryCursor())
247
->setObject(last($page));
248
249
if ($this->rawCursorRow) {
250
$cursor->setRawRow($this->rawCursorRow);
251
}
252
253
$this->setInternalCursorObject($cursor);
254
}
255
256
final public function getFerretMetadata() {
257
if (!$this->supportsFerretEngine()) {
258
throw new Exception(
259
pht(
260
'Unable to retrieve Ferret engine metadata, this class ("%s") does '.
261
'not support the Ferret engine.',
262
get_class($this)));
263
}
264
265
return $this->ferretMetadata;
266
}
267
268
protected function loadPage() {
269
$object = $this->newResultObject();
270
271
if (!$object instanceof PhabricatorLiskDAO) {
272
throw new Exception(
273
pht(
274
'Query class ("%s") did not return the correct type of object '.
275
'from "newResultObject()" (expected a subclass of '.
276
'"PhabricatorLiskDAO", found "%s"). Return an object of the '.
277
'expected type (this is common), or implement a custom '.
278
'"loadPage()" method (this is unusual in modern code).',
279
get_class($this),
280
phutil_describe_type($object)));
281
}
282
283
return $this->loadStandardPage($object);
284
}
285
286
protected function loadStandardPage(PhabricatorLiskDAO $table) {
287
$rows = $this->loadStandardPageRows($table);
288
return $table->loadAllFromArray($rows);
289
}
290
291
protected function loadStandardPageRows(PhabricatorLiskDAO $table) {
292
$conn = $table->establishConnection('r');
293
return $this->loadStandardPageRowsWithConnection(
294
$conn,
295
$table->getTableName());
296
}
297
298
protected function loadStandardPageRowsWithConnection(
299
AphrontDatabaseConnection $conn,
300
$table_name) {
301
302
$query = $this->buildStandardPageQuery($conn, $table_name);
303
304
$rows = queryfx_all($conn, '%Q', $query);
305
$rows = $this->didLoadRawRows($rows);
306
307
return $rows;
308
}
309
310
protected function buildStandardPageQuery(
311
AphrontDatabaseConnection $conn,
312
$table_name) {
313
314
$table_alias = $this->getPrimaryTableAlias();
315
if ($table_alias === null) {
316
$table_alias = qsprintf($conn, '');
317
} else {
318
$table_alias = qsprintf($conn, '%T', $table_alias);
319
}
320
321
return qsprintf(
322
$conn,
323
'%Q FROM %T %Q %Q %Q %Q %Q %Q %Q',
324
$this->buildSelectClause($conn),
325
$table_name,
326
$table_alias,
327
$this->buildJoinClause($conn),
328
$this->buildWhereClause($conn),
329
$this->buildGroupClause($conn),
330
$this->buildHavingClause($conn),
331
$this->buildOrderClause($conn),
332
$this->buildLimitClause($conn));
333
}
334
335
protected function didLoadRawRows(array $rows) {
336
$this->rawCursorRow = last($rows);
337
338
if ($this->ferretEngine) {
339
foreach ($rows as $row) {
340
$phid = $row['phid'];
341
342
$metadata = id(new PhabricatorFerretMetadata())
343
->setPHID($phid)
344
->setEngine($this->ferretEngine)
345
->setRelevance(idx($row, self::FULLTEXT_RANK));
346
347
$this->ferretMetadata[$phid] = $metadata;
348
349
unset($row[self::FULLTEXT_RANK]);
350
unset($row[self::FULLTEXT_MODIFIED]);
351
unset($row[self::FULLTEXT_CREATED]);
352
}
353
}
354
355
return $rows;
356
}
357
358
final protected function buildLimitClause(AphrontDatabaseConnection $conn) {
359
if ($this->shouldLimitResults()) {
360
$limit = $this->getRawResultLimit();
361
if ($limit) {
362
return qsprintf($conn, 'LIMIT %d', $limit);
363
}
364
}
365
366
return qsprintf($conn, '');
367
}
368
369
protected function shouldLimitResults() {
370
return true;
371
}
372
373
final protected function didLoadResults(array $results) {
374
if ($this->getIsQueryOrderReversed()) {
375
$results = array_reverse($results, $preserve_keys = true);
376
}
377
378
return $results;
379
}
380
381
final public function newIterator() {
382
return new PhabricatorQueryIterator($this);
383
}
384
385
final public function executeWithCursorPager(AphrontCursorPagerView $pager) {
386
$limit = $pager->getPageSize();
387
388
$this->setLimit($limit + 1);
389
390
$after_id = phutil_string_cast($pager->getAfterID());
391
$before_id = phutil_string_cast($pager->getBeforeID());
392
393
if (phutil_nonempty_string($after_id)) {
394
$this->setExternalCursorString($after_id);
395
} else if (phutil_nonempty_string($before_id)) {
396
$this->setExternalCursorString($before_id);
397
$this->setIsQueryOrderReversed(true);
398
}
399
400
$results = $this->execute();
401
$count = count($results);
402
403
$sliced_results = $pager->sliceResults($results);
404
if ($sliced_results) {
405
406
// If we have results, generate external-facing cursors from the visible
407
// results. This stops us from leaking any internal details about objects
408
// which we loaded but which were not visible to the viewer.
409
410
if ($pager->getBeforeID() || ($count > $limit)) {
411
$last_object = last($sliced_results);
412
$cursor = $this->getExternalCursorStringForResult($last_object);
413
$pager->setNextPageID($cursor);
414
}
415
416
if ($pager->getAfterID() ||
417
($pager->getBeforeID() && ($count > $limit))) {
418
$head_object = head($sliced_results);
419
$cursor = $this->getExternalCursorStringForResult($head_object);
420
$pager->setPrevPageID($cursor);
421
}
422
}
423
424
return $sliced_results;
425
}
426
427
428
/**
429
* Return the alias this query uses to identify the primary table.
430
*
431
* Some automatic query constructions may need to be qualified with a table
432
* alias if the query performs joins which make column names ambiguous. If
433
* this is the case, return the alias for the primary table the query
434
* uses; generally the object table which has `id` and `phid` columns.
435
*
436
* @return string Alias for the primary table.
437
*/
438
protected function getPrimaryTableAlias() {
439
return null;
440
}
441
442
public function newResultObject() {
443
return null;
444
}
445
446
447
/* -( Building Query Clauses )--------------------------------------------- */
448
449
450
/**
451
* @task clauses
452
*/
453
protected function buildSelectClause(AphrontDatabaseConnection $conn) {
454
$parts = $this->buildSelectClauseParts($conn);
455
return $this->formatSelectClause($conn, $parts);
456
}
457
458
459
/**
460
* @task clauses
461
*/
462
protected function buildSelectClauseParts(AphrontDatabaseConnection $conn) {
463
$select = array();
464
465
$alias = $this->getPrimaryTableAlias();
466
if ($alias) {
467
$select[] = qsprintf($conn, '%T.*', $alias);
468
} else {
469
$select[] = qsprintf($conn, '*');
470
}
471
472
$select[] = $this->buildEdgeLogicSelectClause($conn);
473
$select[] = $this->buildFerretSelectClause($conn);
474
475
return $select;
476
}
477
478
479
/**
480
* @task clauses
481
*/
482
protected function buildJoinClause(AphrontDatabaseConnection $conn) {
483
$joins = $this->buildJoinClauseParts($conn);
484
return $this->formatJoinClause($conn, $joins);
485
}
486
487
488
/**
489
* @task clauses
490
*/
491
protected function buildJoinClauseParts(AphrontDatabaseConnection $conn) {
492
$joins = array();
493
$joins[] = $this->buildEdgeLogicJoinClause($conn);
494
$joins[] = $this->buildApplicationSearchJoinClause($conn);
495
$joins[] = $this->buildNgramsJoinClause($conn);
496
$joins[] = $this->buildFerretJoinClause($conn);
497
return $joins;
498
}
499
500
501
/**
502
* @task clauses
503
*/
504
protected function buildWhereClause(AphrontDatabaseConnection $conn) {
505
$where = $this->buildWhereClauseParts($conn);
506
return $this->formatWhereClause($conn, $where);
507
}
508
509
510
/**
511
* @task clauses
512
*/
513
protected function buildWhereClauseParts(AphrontDatabaseConnection $conn) {
514
$where = array();
515
$where[] = $this->buildPagingWhereClause($conn);
516
$where[] = $this->buildEdgeLogicWhereClause($conn);
517
$where[] = $this->buildSpacesWhereClause($conn);
518
$where[] = $this->buildNgramsWhereClause($conn);
519
$where[] = $this->buildFerretWhereClause($conn);
520
$where[] = $this->buildApplicationSearchWhereClause($conn);
521
return $where;
522
}
523
524
525
/**
526
* @task clauses
527
*/
528
protected function buildHavingClause(AphrontDatabaseConnection $conn) {
529
$having = $this->buildHavingClauseParts($conn);
530
$having[] = $this->buildPagingHavingClause($conn);
531
return $this->formatHavingClause($conn, $having);
532
}
533
534
535
/**
536
* @task clauses
537
*/
538
protected function buildHavingClauseParts(AphrontDatabaseConnection $conn) {
539
$having = array();
540
$having[] = $this->buildEdgeLogicHavingClause($conn);
541
return $having;
542
}
543
544
545
/**
546
* @task clauses
547
*/
548
protected function buildGroupClause(AphrontDatabaseConnection $conn) {
549
if (!$this->shouldGroupQueryResultRows()) {
550
return qsprintf($conn, '');
551
}
552
553
return qsprintf(
554
$conn,
555
'GROUP BY %Q',
556
$this->getApplicationSearchObjectPHIDColumn($conn));
557
}
558
559
560
/**
561
* @task clauses
562
*/
563
protected function shouldGroupQueryResultRows() {
564
if ($this->shouldGroupEdgeLogicResultRows()) {
565
return true;
566
}
567
568
if ($this->getApplicationSearchMayJoinMultipleRows()) {
569
return true;
570
}
571
572
if ($this->shouldGroupNgramResultRows()) {
573
return true;
574
}
575
576
if ($this->shouldGroupFerretResultRows()) {
577
return true;
578
}
579
580
return false;
581
}
582
583
584
585
/* -( Paging )------------------------------------------------------------- */
586
587
588
private function buildPagingWhereClause(AphrontDatabaseConnection $conn) {
589
if ($this->shouldPageWithHavingClause()) {
590
return null;
591
}
592
593
return $this->buildPagingClause($conn);
594
}
595
596
private function buildPagingHavingClause(AphrontDatabaseConnection $conn) {
597
if (!$this->shouldPageWithHavingClause()) {
598
return null;
599
}
600
601
return $this->buildPagingClause($conn);
602
}
603
604
private function shouldPageWithHavingClause() {
605
// If any of the paging conditions reference dynamic columns, we need to
606
// put the paging conditions in a "HAVING" clause instead of a "WHERE"
607
// clause.
608
609
// For example, this happens when paging on the Ferret "rank" column,
610
// since the "rank" value is computed dynamically in the SELECT statement.
611
612
$orderable = $this->getOrderableColumns();
613
$vector = $this->getOrderVector();
614
615
foreach ($vector as $order) {
616
$key = $order->getOrderKey();
617
$column = $orderable[$key];
618
619
if (!empty($column['having'])) {
620
return true;
621
}
622
}
623
624
return false;
625
}
626
627
/**
628
* @task paging
629
*/
630
protected function buildPagingClause(AphrontDatabaseConnection $conn) {
631
$orderable = $this->getOrderableColumns();
632
$vector = $this->getQueryableOrderVector();
633
634
// If we don't have a cursor object yet, it means we're trying to load
635
// the first result page. We may need to build a cursor object from the
636
// external string, or we may not need a paging clause yet.
637
$cursor_object = $this->getInternalCursorObject();
638
if (!$cursor_object) {
639
$external_cursor = $this->getExternalCursorString();
640
if ($external_cursor !== null) {
641
$cursor_object = $this->getInternalCursorFromExternalCursor(
642
$external_cursor);
643
}
644
}
645
646
// If we still don't have a cursor object, this is the first result page
647
// and we aren't paging it. We don't need to build a paging clause.
648
if (!$cursor_object) {
649
return qsprintf($conn, '');
650
}
651
652
$reversed = $this->getIsQueryOrderReversed();
653
654
$keys = array();
655
foreach ($vector as $order) {
656
$keys[] = $order->getOrderKey();
657
}
658
$keys = array_fuse($keys);
659
660
$value_map = $this->getPagingMapFromCursorObject(
661
$cursor_object,
662
$keys);
663
664
$columns = array();
665
foreach ($vector as $order) {
666
$key = $order->getOrderKey();
667
668
$column = $orderable[$key];
669
$column['value'] = $value_map[$key];
670
671
// If the vector component is reversed, we need to reverse whatever the
672
// order of the column is.
673
if ($order->getIsReversed()) {
674
$column['reverse'] = !idx($column, 'reverse', false);
675
}
676
677
$columns[] = $column;
678
}
679
680
return $this->buildPagingClauseFromMultipleColumns(
681
$conn,
682
$columns,
683
array(
684
'reversed' => $reversed,
685
));
686
}
687
688
689
/**
690
* Simplifies the task of constructing a paging clause across multiple
691
* columns. In the general case, this looks like:
692
*
693
* A > a OR (A = a AND B > b) OR (A = a AND B = b AND C > c)
694
*
695
* To build a clause, specify the name, type, and value of each column
696
* to include:
697
*
698
* $this->buildPagingClauseFromMultipleColumns(
699
* $conn_r,
700
* array(
701
* array(
702
* 'table' => 't',
703
* 'column' => 'title',
704
* 'type' => 'string',
705
* 'value' => $cursor->getTitle(),
706
* 'reverse' => true,
707
* ),
708
* array(
709
* 'table' => 't',
710
* 'column' => 'id',
711
* 'type' => 'int',
712
* 'value' => $cursor->getID(),
713
* ),
714
* ),
715
* array(
716
* 'reversed' => $is_reversed,
717
* ));
718
*
719
* This method will then return a composable clause for inclusion in WHERE.
720
*
721
* @param AphrontDatabaseConnection Connection query will execute on.
722
* @param list<map> Column description dictionaries.
723
* @param map Additional construction options.
724
* @return string Query clause.
725
* @task paging
726
*/
727
final protected function buildPagingClauseFromMultipleColumns(
728
AphrontDatabaseConnection $conn,
729
array $columns,
730
array $options) {
731
732
foreach ($columns as $column) {
733
PhutilTypeSpec::checkMap(
734
$column,
735
array(
736
'table' => 'optional string|null',
737
'column' => 'string',
738
'value' => 'wild',
739
'type' => 'string',
740
'reverse' => 'optional bool',
741
'unique' => 'optional bool',
742
'null' => 'optional string|null',
743
'requires-ferret' => 'optional bool',
744
'having' => 'optional bool',
745
));
746
}
747
748
PhutilTypeSpec::checkMap(
749
$options,
750
array(
751
'reversed' => 'optional bool',
752
));
753
754
$is_query_reversed = idx($options, 'reversed', false);
755
756
$clauses = array();
757
$accumulated = array();
758
$last_key = last_key($columns);
759
foreach ($columns as $key => $column) {
760
$type = $column['type'];
761
762
$null = idx($column, 'null');
763
if ($column['value'] === null) {
764
if ($null) {
765
$value = null;
766
} else {
767
throw new Exception(
768
pht(
769
'Column "%s" has null value, but does not specify a null '.
770
'behavior.',
771
$key));
772
}
773
} else {
774
switch ($type) {
775
case 'int':
776
$value = qsprintf($conn, '%d', $column['value']);
777
break;
778
case 'float':
779
$value = qsprintf($conn, '%f', $column['value']);
780
break;
781
case 'string':
782
$value = qsprintf($conn, '%s', $column['value']);
783
break;
784
default:
785
throw new Exception(
786
pht(
787
'Column "%s" has unknown column type "%s".',
788
$column['column'],
789
$type));
790
}
791
}
792
793
$is_column_reversed = idx($column, 'reverse', false);
794
$reverse = ($is_query_reversed xor $is_column_reversed);
795
796
$clause = $accumulated;
797
798
$table_name = idx($column, 'table');
799
$column_name = $column['column'];
800
if ($table_name !== null) {
801
$field = qsprintf($conn, '%T.%T', $table_name, $column_name);
802
} else {
803
$field = qsprintf($conn, '%T', $column_name);
804
}
805
806
$parts = array();
807
if ($null) {
808
$can_page_if_null = ($null === 'head');
809
$can_page_if_nonnull = ($null === 'tail');
810
811
if ($reverse) {
812
$can_page_if_null = !$can_page_if_null;
813
$can_page_if_nonnull = !$can_page_if_nonnull;
814
}
815
816
$subclause = null;
817
if ($can_page_if_null && $value === null) {
818
$parts[] = qsprintf(
819
$conn,
820
'(%Q IS NOT NULL)',
821
$field);
822
} else if ($can_page_if_nonnull && $value !== null) {
823
$parts[] = qsprintf(
824
$conn,
825
'(%Q IS NULL)',
826
$field);
827
}
828
}
829
830
if ($value !== null) {
831
$parts[] = qsprintf(
832
$conn,
833
'%Q %Q %Q',
834
$field,
835
$reverse ? qsprintf($conn, '>') : qsprintf($conn, '<'),
836
$value);
837
}
838
839
if ($parts) {
840
$clause[] = qsprintf($conn, '%LO', $parts);
841
}
842
843
if ($clause) {
844
$clauses[] = qsprintf($conn, '%LA', $clause);
845
}
846
847
if ($value === null) {
848
$accumulated[] = qsprintf(
849
$conn,
850
'%Q IS NULL',
851
$field);
852
} else {
853
$accumulated[] = qsprintf(
854
$conn,
855
'%Q = %Q',
856
$field,
857
$value);
858
}
859
}
860
861
if ($clauses) {
862
return qsprintf($conn, '%LO', $clauses);
863
}
864
865
return qsprintf($conn, '');
866
}
867
868
869
/* -( Result Ordering )---------------------------------------------------- */
870
871
872
/**
873
* Select a result ordering.
874
*
875
* This is a high-level method which selects an ordering from a predefined
876
* list of builtin orders, as provided by @{method:getBuiltinOrders}. These
877
* options are user-facing and not exhaustive, but are generally convenient
878
* and meaningful.
879
*
880
* You can also use @{method:setOrderVector} to specify a low-level ordering
881
* across individual orderable columns. This offers greater control but is
882
* also more involved.
883
*
884
* @param string Key of a builtin order supported by this query.
885
* @return this
886
* @task order
887
*/
888
public function setOrder($order) {
889
$aliases = $this->getBuiltinOrderAliasMap();
890
891
if (empty($aliases[$order])) {
892
throw new Exception(
893
pht(
894
'Query "%s" does not support a builtin order "%s". Supported orders '.
895
'are: %s.',
896
get_class($this),
897
$order,
898
implode(', ', array_keys($aliases))));
899
}
900
901
$this->builtinOrder = $aliases[$order];
902
$this->orderVector = null;
903
904
return $this;
905
}
906
907
908
/**
909
* Set a grouping order to apply before primary result ordering.
910
*
911
* This allows you to preface the query order vector with additional orders,
912
* so you can effect "group by" queries while still respecting "order by".
913
*
914
* This is a high-level method which works alongside @{method:setOrder}. For
915
* lower-level control over order vectors, use @{method:setOrderVector}.
916
*
917
* @param PhabricatorQueryOrderVector|list<string> List of order keys.
918
* @return this
919
* @task order
920
*/
921
public function setGroupVector($vector) {
922
$this->groupVector = $vector;
923
$this->orderVector = null;
924
925
return $this;
926
}
927
928
929
/**
930
* Get builtin orders for this class.
931
*
932
* In application UIs, we want to be able to present users with a small
933
* selection of meaningful order options (like "Order by Title") rather than
934
* an exhaustive set of column ordering options.
935
*
936
* Meaningful user-facing orders are often really orders across multiple
937
* columns: for example, a "title" ordering is usually implemented as a
938
* "title, id" ordering under the hood.
939
*
940
* Builtin orders provide a mapping from convenient, understandable
941
* user-facing orders to implementations.
942
*
943
* A builtin order should provide these keys:
944
*
945
* - `vector` (`list<string>`): The actual order vector to use.
946
* - `name` (`string`): Human-readable order name.
947
*
948
* @return map<string, wild> Map from builtin order keys to specification.
949
* @task order
950
*/
951
public function getBuiltinOrders() {
952
$orders = array(
953
'newest' => array(
954
'vector' => array('id'),
955
'name' => pht('Creation (Newest First)'),
956
'aliases' => array('created'),
957
),
958
'oldest' => array(
959
'vector' => array('-id'),
960
'name' => pht('Creation (Oldest First)'),
961
),
962
);
963
964
$object = $this->newResultObject();
965
if ($object instanceof PhabricatorCustomFieldInterface) {
966
$list = PhabricatorCustomField::getObjectFields(
967
$object,
968
PhabricatorCustomField::ROLE_APPLICATIONSEARCH);
969
foreach ($list->getFields() as $field) {
970
$index = $field->buildOrderIndex();
971
if (!$index) {
972
continue;
973
}
974
975
$legacy_key = 'custom:'.$field->getFieldKey();
976
$modern_key = $field->getModernFieldKey();
977
978
$orders[$modern_key] = array(
979
'vector' => array($modern_key, 'id'),
980
'name' => $field->getFieldName(),
981
'aliases' => array($legacy_key),
982
);
983
984
$orders['-'.$modern_key] = array(
985
'vector' => array('-'.$modern_key, '-id'),
986
'name' => pht('%s (Reversed)', $field->getFieldName()),
987
);
988
}
989
}
990
991
if ($this->supportsFerretEngine()) {
992
$orders['relevance'] = array(
993
'vector' => array('rank', 'fulltext-modified', 'id'),
994
'name' => pht('Relevance'),
995
);
996
}
997
998
return $orders;
999
}
1000
1001
public function getBuiltinOrderAliasMap() {
1002
$orders = $this->getBuiltinOrders();
1003
1004
$map = array();
1005
foreach ($orders as $key => $order) {
1006
$keys = array();
1007
$keys[] = $key;
1008
foreach (idx($order, 'aliases', array()) as $alias) {
1009
$keys[] = $alias;
1010
}
1011
1012
foreach ($keys as $alias) {
1013
if (isset($map[$alias])) {
1014
throw new Exception(
1015
pht(
1016
'Two builtin orders ("%s" and "%s") define the same key or '.
1017
'alias ("%s"). Each order alias and key must be unique and '.
1018
'identify a single order.',
1019
$key,
1020
$map[$alias],
1021
$alias));
1022
}
1023
$map[$alias] = $key;
1024
}
1025
}
1026
1027
return $map;
1028
}
1029
1030
1031
/**
1032
* Set a low-level column ordering.
1033
*
1034
* This is a low-level method which offers granular control over column
1035
* ordering. In most cases, applications can more easily use
1036
* @{method:setOrder} to choose a high-level builtin order.
1037
*
1038
* To set an order vector, specify a list of order keys as provided by
1039
* @{method:getOrderableColumns}.
1040
*
1041
* @param PhabricatorQueryOrderVector|list<string> List of order keys.
1042
* @return this
1043
* @task order
1044
*/
1045
public function setOrderVector($vector) {
1046
$vector = PhabricatorQueryOrderVector::newFromVector($vector);
1047
1048
$orderable = $this->getOrderableColumns();
1049
1050
// Make sure that all the components identify valid columns.
1051
$unique = array();
1052
foreach ($vector as $order) {
1053
$key = $order->getOrderKey();
1054
if (empty($orderable[$key])) {
1055
$valid = implode(', ', array_keys($orderable));
1056
throw new Exception(
1057
pht(
1058
'This query ("%s") does not support sorting by order key "%s". '.
1059
'Supported orders are: %s.',
1060
get_class($this),
1061
$key,
1062
$valid));
1063
}
1064
1065
$unique[$key] = idx($orderable[$key], 'unique', false);
1066
}
1067
1068
// Make sure that the last column is unique so that this is a strong
1069
// ordering which can be used for paging.
1070
$last = last($unique);
1071
if ($last !== true) {
1072
throw new Exception(
1073
pht(
1074
'Order vector "%s" is invalid: the last column in an order must '.
1075
'be a column with unique values, but "%s" is not unique.',
1076
$vector->getAsString(),
1077
last_key($unique)));
1078
}
1079
1080
// Make sure that other columns are not unique; an ordering like "id, name"
1081
// does not make sense because only "id" can ever have an effect.
1082
array_pop($unique);
1083
foreach ($unique as $key => $is_unique) {
1084
if ($is_unique) {
1085
throw new Exception(
1086
pht(
1087
'Order vector "%s" is invalid: only the last column in an order '.
1088
'may be unique, but "%s" is a unique column and not the last '.
1089
'column in the order.',
1090
$vector->getAsString(),
1091
$key));
1092
}
1093
}
1094
1095
$this->orderVector = $vector;
1096
return $this;
1097
}
1098
1099
1100
/**
1101
* Get the effective order vector.
1102
*
1103
* @return PhabricatorQueryOrderVector Effective vector.
1104
* @task order
1105
*/
1106
protected function getOrderVector() {
1107
if (!$this->orderVector) {
1108
if ($this->builtinOrder !== null) {
1109
$builtin_order = idx($this->getBuiltinOrders(), $this->builtinOrder);
1110
$vector = $builtin_order['vector'];
1111
} else {
1112
$vector = $this->getDefaultOrderVector();
1113
}
1114
1115
if ($this->groupVector) {
1116
$group = PhabricatorQueryOrderVector::newFromVector($this->groupVector);
1117
$group->appendVector($vector);
1118
$vector = $group;
1119
}
1120
1121
$vector = PhabricatorQueryOrderVector::newFromVector($vector);
1122
1123
// We call setOrderVector() here to apply checks to the default vector.
1124
// This catches any errors in the implementation.
1125
$this->setOrderVector($vector);
1126
}
1127
1128
return $this->orderVector;
1129
}
1130
1131
1132
/**
1133
* @task order
1134
*/
1135
protected function getDefaultOrderVector() {
1136
return array('id');
1137
}
1138
1139
1140
/**
1141
* @task order
1142
*/
1143
public function getOrderableColumns() {
1144
$cache = PhabricatorCaches::getRequestCache();
1145
$class = get_class($this);
1146
$cache_key = 'query.orderablecolumns.'.$class;
1147
1148
$columns = $cache->getKey($cache_key);
1149
if ($columns !== null) {
1150
return $columns;
1151
}
1152
1153
$columns = array(
1154
'id' => array(
1155
'table' => $this->getPrimaryTableAlias(),
1156
'column' => 'id',
1157
'reverse' => false,
1158
'type' => 'int',
1159
'unique' => true,
1160
),
1161
);
1162
1163
$object = $this->newResultObject();
1164
if ($object instanceof PhabricatorCustomFieldInterface) {
1165
$list = PhabricatorCustomField::getObjectFields(
1166
$object,
1167
PhabricatorCustomField::ROLE_APPLICATIONSEARCH);
1168
foreach ($list->getFields() as $field) {
1169
$index = $field->buildOrderIndex();
1170
if (!$index) {
1171
continue;
1172
}
1173
1174
$digest = $field->getFieldIndex();
1175
1176
$key = $field->getModernFieldKey();
1177
1178
$columns[$key] = array(
1179
'table' => 'appsearch_order_'.$digest,
1180
'column' => 'indexValue',
1181
'type' => $index->getIndexValueType(),
1182
'null' => 'tail',
1183
'customfield' => true,
1184
'customfield.index.table' => $index->getTableName(),
1185
'customfield.index.key' => $digest,
1186
);
1187
}
1188
}
1189
1190
if ($this->supportsFerretEngine()) {
1191
$columns['rank'] = array(
1192
'table' => null,
1193
'column' => self::FULLTEXT_RANK,
1194
'type' => 'int',
1195
'requires-ferret' => true,
1196
'having' => true,
1197
);
1198
$columns['fulltext-created'] = array(
1199
'table' => null,
1200
'column' => self::FULLTEXT_CREATED,
1201
'type' => 'int',
1202
'requires-ferret' => true,
1203
);
1204
$columns['fulltext-modified'] = array(
1205
'table' => null,
1206
'column' => self::FULLTEXT_MODIFIED,
1207
'type' => 'int',
1208
'requires-ferret' => true,
1209
);
1210
}
1211
1212
$cache->setKey($cache_key, $columns);
1213
1214
return $columns;
1215
}
1216
1217
1218
/**
1219
* @task order
1220
*/
1221
final protected function buildOrderClause(
1222
AphrontDatabaseConnection $conn,
1223
$for_union = false) {
1224
1225
$orderable = $this->getOrderableColumns();
1226
$vector = $this->getQueryableOrderVector();
1227
1228
$parts = array();
1229
foreach ($vector as $order) {
1230
$part = $orderable[$order->getOrderKey()];
1231
1232
if ($order->getIsReversed()) {
1233
$part['reverse'] = !idx($part, 'reverse', false);
1234
}
1235
$parts[] = $part;
1236
}
1237
1238
return $this->formatOrderClause($conn, $parts, $for_union);
1239
}
1240
1241
/**
1242
* @task order
1243
*/
1244
private function getQueryableOrderVector() {
1245
$vector = $this->getOrderVector();
1246
$orderable = $this->getOrderableColumns();
1247
1248
$keep = array();
1249
foreach ($vector as $order) {
1250
$column = $orderable[$order->getOrderKey()];
1251
1252
// If this is a Ferret fulltext column but the query doesn't actually
1253
// have a fulltext query, we'll skip most of the Ferret stuff and won't
1254
// actually have the columns in the result set. Just skip them.
1255
if (!empty($column['requires-ferret'])) {
1256
if (!$this->getFerretTokens()) {
1257
continue;
1258
}
1259
}
1260
1261
$keep[] = $order->getAsScalar();
1262
}
1263
1264
return PhabricatorQueryOrderVector::newFromVector($keep);
1265
}
1266
1267
/**
1268
* @task order
1269
*/
1270
protected function formatOrderClause(
1271
AphrontDatabaseConnection $conn,
1272
array $parts,
1273
$for_union = false) {
1274
1275
$is_query_reversed = $this->getIsQueryOrderReversed();
1276
1277
$sql = array();
1278
foreach ($parts as $key => $part) {
1279
$is_column_reversed = !empty($part['reverse']);
1280
1281
$descending = true;
1282
if ($is_query_reversed) {
1283
$descending = !$descending;
1284
}
1285
1286
if ($is_column_reversed) {
1287
$descending = !$descending;
1288
}
1289
1290
$table = idx($part, 'table');
1291
1292
// When we're building an ORDER BY clause for a sequence of UNION
1293
// statements, we can't refer to tables from the subqueries.
1294
if ($for_union) {
1295
$table = null;
1296
}
1297
1298
$column = $part['column'];
1299
1300
if ($table !== null) {
1301
$field = qsprintf($conn, '%T.%T', $table, $column);
1302
} else {
1303
$field = qsprintf($conn, '%T', $column);
1304
}
1305
1306
$null = idx($part, 'null');
1307
if ($null) {
1308
switch ($null) {
1309
case 'head':
1310
$null_field = qsprintf($conn, '(%Q IS NULL)', $field);
1311
break;
1312
case 'tail':
1313
$null_field = qsprintf($conn, '(%Q IS NOT NULL)', $field);
1314
break;
1315
default:
1316
throw new Exception(
1317
pht(
1318
'NULL value "%s" is invalid. Valid values are "head" and '.
1319
'"tail".',
1320
$null));
1321
}
1322
1323
if ($descending) {
1324
$sql[] = qsprintf($conn, '%Q DESC', $null_field);
1325
} else {
1326
$sql[] = qsprintf($conn, '%Q ASC', $null_field);
1327
}
1328
}
1329
1330
if ($descending) {
1331
$sql[] = qsprintf($conn, '%Q DESC', $field);
1332
} else {
1333
$sql[] = qsprintf($conn, '%Q ASC', $field);
1334
}
1335
}
1336
1337
return qsprintf($conn, 'ORDER BY %LQ', $sql);
1338
}
1339
1340
1341
/* -( Application Search )------------------------------------------------- */
1342
1343
1344
/**
1345
* Constrain the query with an ApplicationSearch index, requiring field values
1346
* contain at least one of the values in a set.
1347
*
1348
* This constraint can build the most common types of queries, like:
1349
*
1350
* - Find users with shirt sizes "X" or "XL".
1351
* - Find shoes with size "13".
1352
*
1353
* @param PhabricatorCustomFieldIndexStorage Table where the index is stored.
1354
* @param string|list<string> One or more values to filter by.
1355
* @return this
1356
* @task appsearch
1357
*/
1358
public function withApplicationSearchContainsConstraint(
1359
PhabricatorCustomFieldIndexStorage $index,
1360
$value) {
1361
1362
$values = (array)$value;
1363
1364
$data_values = array();
1365
$constraint_values = array();
1366
foreach ($values as $value) {
1367
if ($value instanceof PhabricatorQueryConstraint) {
1368
$constraint_values[] = $value;
1369
} else {
1370
$data_values[] = $value;
1371
}
1372
}
1373
1374
$alias = 'appsearch_'.count($this->applicationSearchConstraints);
1375
1376
$this->applicationSearchConstraints[] = array(
1377
'type' => $index->getIndexValueType(),
1378
'cond' => '=',
1379
'table' => $index->getTableName(),
1380
'index' => $index->getIndexKey(),
1381
'alias' => $alias,
1382
'value' => $values,
1383
'data' => $data_values,
1384
'constraints' => $constraint_values,
1385
);
1386
1387
return $this;
1388
}
1389
1390
1391
/**
1392
* Constrain the query with an ApplicationSearch index, requiring values
1393
* exist in a given range.
1394
*
1395
* This constraint is useful for expressing date ranges:
1396
*
1397
* - Find events between July 1st and July 7th.
1398
*
1399
* The ends of the range are inclusive, so a `$min` of `3` and a `$max` of
1400
* `5` will match fields with values `3`, `4`, or `5`. Providing `null` for
1401
* either end of the range will leave that end of the constraint open.
1402
*
1403
* @param PhabricatorCustomFieldIndexStorage Table where the index is stored.
1404
* @param int|null Minimum permissible value, inclusive.
1405
* @param int|null Maximum permissible value, inclusive.
1406
* @return this
1407
* @task appsearch
1408
*/
1409
public function withApplicationSearchRangeConstraint(
1410
PhabricatorCustomFieldIndexStorage $index,
1411
$min,
1412
$max) {
1413
1414
$index_type = $index->getIndexValueType();
1415
if ($index_type != 'int') {
1416
throw new Exception(
1417
pht(
1418
'Attempting to apply a range constraint to a field with index type '.
1419
'"%s", expected type "%s".',
1420
$index_type,
1421
'int'));
1422
}
1423
1424
$alias = 'appsearch_'.count($this->applicationSearchConstraints);
1425
1426
$this->applicationSearchConstraints[] = array(
1427
'type' => $index->getIndexValueType(),
1428
'cond' => 'range',
1429
'table' => $index->getTableName(),
1430
'index' => $index->getIndexKey(),
1431
'alias' => $alias,
1432
'value' => array($min, $max),
1433
'data' => null,
1434
'constraints' => null,
1435
);
1436
1437
return $this;
1438
}
1439
1440
1441
/**
1442
* Get the name of the query's primary object PHID column, for constructing
1443
* JOIN clauses. Normally (and by default) this is just `"phid"`, but it may
1444
* be something more exotic.
1445
*
1446
* See @{method:getPrimaryTableAlias} if the column needs to be qualified with
1447
* a table alias.
1448
*
1449
* @param AphrontDatabaseConnection Connection executing queries.
1450
* @return PhutilQueryString Column name.
1451
* @task appsearch
1452
*/
1453
protected function getApplicationSearchObjectPHIDColumn(
1454
AphrontDatabaseConnection $conn) {
1455
1456
if ($this->getPrimaryTableAlias()) {
1457
return qsprintf($conn, '%T.phid', $this->getPrimaryTableAlias());
1458
} else {
1459
return qsprintf($conn, 'phid');
1460
}
1461
}
1462
1463
1464
/**
1465
* Determine if the JOINs built by ApplicationSearch might cause each primary
1466
* object to return multiple result rows. Generally, this means the query
1467
* needs an extra GROUP BY clause.
1468
*
1469
* @return bool True if the query may return multiple rows for each object.
1470
* @task appsearch
1471
*/
1472
protected function getApplicationSearchMayJoinMultipleRows() {
1473
foreach ($this->applicationSearchConstraints as $constraint) {
1474
$type = $constraint['type'];
1475
$value = $constraint['value'];
1476
$cond = $constraint['cond'];
1477
1478
switch ($cond) {
1479
case '=':
1480
switch ($type) {
1481
case 'string':
1482
case 'int':
1483
if (count($value) > 1) {
1484
return true;
1485
}
1486
break;
1487
default:
1488
throw new Exception(pht('Unknown index type "%s"!', $type));
1489
}
1490
break;
1491
case 'range':
1492
// NOTE: It's possible to write a custom field where multiple rows
1493
// match a range constraint, but we don't currently ship any in the
1494
// upstream and I can't immediately come up with cases where this
1495
// would make sense.
1496
break;
1497
default:
1498
throw new Exception(pht('Unknown constraint condition "%s"!', $cond));
1499
}
1500
}
1501
1502
return false;
1503
}
1504
1505
1506
/**
1507
* Construct a GROUP BY clause appropriate for ApplicationSearch constraints.
1508
*
1509
* @param AphrontDatabaseConnection Connection executing the query.
1510
* @return string Group clause.
1511
* @task appsearch
1512
*/
1513
protected function buildApplicationSearchGroupClause(
1514
AphrontDatabaseConnection $conn) {
1515
1516
if ($this->getApplicationSearchMayJoinMultipleRows()) {
1517
return qsprintf(
1518
$conn,
1519
'GROUP BY %Q',
1520
$this->getApplicationSearchObjectPHIDColumn($conn));
1521
} else {
1522
return qsprintf($conn, '');
1523
}
1524
}
1525
1526
1527
/**
1528
* Construct a JOIN clause appropriate for applying ApplicationSearch
1529
* constraints.
1530
*
1531
* @param AphrontDatabaseConnection Connection executing the query.
1532
* @return string Join clause.
1533
* @task appsearch
1534
*/
1535
protected function buildApplicationSearchJoinClause(
1536
AphrontDatabaseConnection $conn) {
1537
1538
$joins = array();
1539
foreach ($this->applicationSearchConstraints as $key => $constraint) {
1540
$table = $constraint['table'];
1541
$alias = $constraint['alias'];
1542
$index = $constraint['index'];
1543
$cond = $constraint['cond'];
1544
$phid_column = $this->getApplicationSearchObjectPHIDColumn($conn);
1545
switch ($cond) {
1546
case '=':
1547
// Figure out whether we need to do a LEFT JOIN or not. We need to
1548
// LEFT JOIN if we're going to select "IS NULL" rows.
1549
$join_type = qsprintf($conn, 'JOIN');
1550
foreach ($constraint['constraints'] as $query_constraint) {
1551
$op = $query_constraint->getOperator();
1552
if ($op === PhabricatorQueryConstraint::OPERATOR_NULL) {
1553
$join_type = qsprintf($conn, 'LEFT JOIN');
1554
break;
1555
}
1556
}
1557
1558
$joins[] = qsprintf(
1559
$conn,
1560
'%Q %T %T ON %T.objectPHID = %Q
1561
AND %T.indexKey = %s',
1562
$join_type,
1563
$table,
1564
$alias,
1565
$alias,
1566
$phid_column,
1567
$alias,
1568
$index);
1569
break;
1570
case 'range':
1571
list($min, $max) = $constraint['value'];
1572
if (($min === null) && ($max === null)) {
1573
// If there's no actual range constraint, just move on.
1574
break;
1575
}
1576
1577
if ($min === null) {
1578
$constraint_clause = qsprintf(
1579
$conn,
1580
'%T.indexValue <= %d',
1581
$alias,
1582
$max);
1583
} else if ($max === null) {
1584
$constraint_clause = qsprintf(
1585
$conn,
1586
'%T.indexValue >= %d',
1587
$alias,
1588
$min);
1589
} else {
1590
$constraint_clause = qsprintf(
1591
$conn,
1592
'%T.indexValue BETWEEN %d AND %d',
1593
$alias,
1594
$min,
1595
$max);
1596
}
1597
1598
$joins[] = qsprintf(
1599
$conn,
1600
'JOIN %T %T ON %T.objectPHID = %Q
1601
AND %T.indexKey = %s
1602
AND (%Q)',
1603
$table,
1604
$alias,
1605
$alias,
1606
$phid_column,
1607
$alias,
1608
$index,
1609
$constraint_clause);
1610
break;
1611
default:
1612
throw new Exception(pht('Unknown constraint condition "%s"!', $cond));
1613
}
1614
}
1615
1616
$phid_column = $this->getApplicationSearchObjectPHIDColumn($conn);
1617
$orderable = $this->getOrderableColumns();
1618
1619
$vector = $this->getOrderVector();
1620
foreach ($vector as $order) {
1621
$spec = $orderable[$order->getOrderKey()];
1622
if (empty($spec['customfield'])) {
1623
continue;
1624
}
1625
1626
$table = $spec['customfield.index.table'];
1627
$alias = $spec['table'];
1628
$key = $spec['customfield.index.key'];
1629
1630
$joins[] = qsprintf(
1631
$conn,
1632
'LEFT JOIN %T %T ON %T.objectPHID = %Q
1633
AND %T.indexKey = %s',
1634
$table,
1635
$alias,
1636
$alias,
1637
$phid_column,
1638
$alias,
1639
$key);
1640
}
1641
1642
if ($joins) {
1643
return qsprintf($conn, '%LJ', $joins);
1644
} else {
1645
return qsprintf($conn, '');
1646
}
1647
}
1648
1649
/**
1650
* Construct a WHERE clause appropriate for applying ApplicationSearch
1651
* constraints.
1652
*
1653
* @param AphrontDatabaseConnection Connection executing the query.
1654
* @return list<string> Where clause parts.
1655
* @task appsearch
1656
*/
1657
protected function buildApplicationSearchWhereClause(
1658
AphrontDatabaseConnection $conn) {
1659
1660
$where = array();
1661
1662
foreach ($this->applicationSearchConstraints as $key => $constraint) {
1663
$alias = $constraint['alias'];
1664
$cond = $constraint['cond'];
1665
$type = $constraint['type'];
1666
1667
$data_values = $constraint['data'];
1668
$constraint_values = $constraint['constraints'];
1669
1670
$constraint_parts = array();
1671
switch ($cond) {
1672
case '=':
1673
if ($data_values) {
1674
switch ($type) {
1675
case 'string':
1676
$constraint_parts[] = qsprintf(
1677
$conn,
1678
'%T.indexValue IN (%Ls)',
1679
$alias,
1680
$data_values);
1681
break;
1682
case 'int':
1683
$constraint_parts[] = qsprintf(
1684
$conn,
1685
'%T.indexValue IN (%Ld)',
1686
$alias,
1687
$data_values);
1688
break;
1689
default:
1690
throw new Exception(pht('Unknown index type "%s"!', $type));
1691
}
1692
}
1693
1694
if ($constraint_values) {
1695
foreach ($constraint_values as $value) {
1696
$op = $value->getOperator();
1697
switch ($op) {
1698
case PhabricatorQueryConstraint::OPERATOR_NULL:
1699
$constraint_parts[] = qsprintf(
1700
$conn,
1701
'%T.indexValue IS NULL',
1702
$alias);
1703
break;
1704
case PhabricatorQueryConstraint::OPERATOR_ANY:
1705
$constraint_parts[] = qsprintf(
1706
$conn,
1707
'%T.indexValue IS NOT NULL',
1708
$alias);
1709
break;
1710
default:
1711
throw new Exception(
1712
pht(
1713
'No support for applying operator "%s" against '.
1714
'index of type "%s".',
1715
$op,
1716
$type));
1717
}
1718
}
1719
}
1720
1721
if ($constraint_parts) {
1722
$where[] = qsprintf($conn, '%LO', $constraint_parts);
1723
}
1724
break;
1725
}
1726
}
1727
1728
return $where;
1729
}
1730
1731
1732
/* -( Integration with CustomField )--------------------------------------- */
1733
1734
1735
/**
1736
* @task customfield
1737
*/
1738
protected function getPagingValueMapForCustomFields(
1739
PhabricatorCustomFieldInterface $object) {
1740
1741
// We have to get the current field values on the cursor object.
1742
$fields = PhabricatorCustomField::getObjectFields(
1743
$object,
1744
PhabricatorCustomField::ROLE_APPLICATIONSEARCH);
1745
$fields->setViewer($this->getViewer());
1746
$fields->readFieldsFromStorage($object);
1747
1748
$map = array();
1749
foreach ($fields->getFields() as $field) {
1750
$map['custom:'.$field->getFieldKey()] = $field->getValueForStorage();
1751
}
1752
1753
return $map;
1754
}
1755
1756
1757
/**
1758
* @task customfield
1759
*/
1760
protected function isCustomFieldOrderKey($key) {
1761
$prefix = 'custom:';
1762
return !strncmp($key, $prefix, strlen($prefix));
1763
}
1764
1765
1766
/* -( Ferret )------------------------------------------------------------- */
1767
1768
1769
public function supportsFerretEngine() {
1770
$object = $this->newResultObject();
1771
return ($object instanceof PhabricatorFerretInterface);
1772
}
1773
1774
public function withFerretQuery(
1775
PhabricatorFerretEngine $engine,
1776
PhabricatorSavedQuery $query) {
1777
1778
if (!$this->supportsFerretEngine()) {
1779
throw new Exception(
1780
pht(
1781
'Query ("%s") does not support the Ferret fulltext engine.',
1782
get_class($this)));
1783
}
1784
1785
$this->ferretEngine = $engine;
1786
$this->ferretQuery = $query;
1787
1788
return $this;
1789
}
1790
1791
public function getFerretTokens() {
1792
if (!$this->supportsFerretEngine()) {
1793
throw new Exception(
1794
pht(
1795
'Query ("%s") does not support the Ferret fulltext engine.',
1796
get_class($this)));
1797
}
1798
1799
return $this->ferretTokens;
1800
}
1801
1802
public function withFerretConstraint(
1803
PhabricatorFerretEngine $engine,
1804
array $fulltext_tokens) {
1805
1806
if (!$this->supportsFerretEngine()) {
1807
throw new Exception(
1808
pht(
1809
'Query ("%s") does not support the Ferret fulltext engine.',
1810
get_class($this)));
1811
}
1812
1813
if ($this->ferretEngine) {
1814
throw new Exception(
1815
pht(
1816
'Query may not have multiple fulltext constraints.'));
1817
}
1818
1819
if (!$fulltext_tokens) {
1820
return $this;
1821
}
1822
1823
$this->ferretEngine = $engine;
1824
$this->ferretTokens = $fulltext_tokens;
1825
1826
$op_absent = PhutilSearchQueryCompiler::OPERATOR_ABSENT;
1827
1828
$default_function = $engine->getDefaultFunctionKey();
1829
$table_map = array();
1830
$idx = 1;
1831
foreach ($this->ferretTokens as $fulltext_token) {
1832
$raw_token = $fulltext_token->getToken();
1833
1834
$function = $raw_token->getFunction();
1835
if ($function === null) {
1836
$function = $default_function;
1837
}
1838
1839
$function_def = $engine->getFunctionForName($function);
1840
1841
// NOTE: The query compiler guarantees that a query can not make a
1842
// field both "present" and "absent", so it's safe to just use the
1843
// first operator we encounter to determine whether the table is
1844
// optional or not.
1845
1846
$operator = $raw_token->getOperator();
1847
$is_optional = ($operator === $op_absent);
1848
1849
if (!isset($table_map[$function])) {
1850
$alias = 'ftfield_'.$idx++;
1851
$table_map[$function] = array(
1852
'alias' => $alias,
1853
'function' => $function_def,
1854
'optional' => $is_optional,
1855
);
1856
}
1857
}
1858
1859
// Join the title field separately so we can rank results.
1860
$table_map['rank'] = array(
1861
'alias' => 'ft_rank',
1862
'function' => $engine->getFunctionForName('title'),
1863
1864
// See T13345. Not every document has a title, so we want to LEFT JOIN
1865
// this table to avoid excluding documents with no title that match
1866
// the query in other fields.
1867
'optional' => true,
1868
);
1869
1870
$this->ferretTables = $table_map;
1871
1872
return $this;
1873
}
1874
1875
protected function buildFerretSelectClause(AphrontDatabaseConnection $conn) {
1876
$select = array();
1877
1878
if (!$this->supportsFerretEngine()) {
1879
return $select;
1880
}
1881
1882
if (!$this->hasFerretOrder()) {
1883
// We only need to SELECT the virtual rank/relevance columns if we're
1884
// actually sorting the results by rank.
1885
return $select;
1886
}
1887
1888
if (!$this->ferretEngine) {
1889
$select[] = qsprintf($conn, '0 AS %T', self::FULLTEXT_RANK);
1890
$select[] = qsprintf($conn, '0 AS %T', self::FULLTEXT_CREATED);
1891
$select[] = qsprintf($conn, '0 AS %T', self::FULLTEXT_MODIFIED);
1892
return $select;
1893
}
1894
1895
$engine = $this->ferretEngine;
1896
$stemmer = $engine->newStemmer();
1897
1898
$op_sub = PhutilSearchQueryCompiler::OPERATOR_SUBSTRING;
1899
$op_not = PhutilSearchQueryCompiler::OPERATOR_NOT;
1900
$table_alias = 'ft_rank';
1901
1902
$parts = array();
1903
foreach ($this->ferretTokens as $fulltext_token) {
1904
$raw_token = $fulltext_token->getToken();
1905
$value = $raw_token->getValue();
1906
1907
if ($raw_token->getOperator() == $op_not) {
1908
// Ignore "not" terms when ranking, since they aren't useful.
1909
continue;
1910
}
1911
1912
if ($raw_token->getOperator() == $op_sub) {
1913
$is_substring = true;
1914
} else {
1915
$is_substring = false;
1916
}
1917
1918
if ($is_substring) {
1919
$parts[] = qsprintf(
1920
$conn,
1921
'IF(%T.rawCorpus LIKE %~, 2, 0)',
1922
$table_alias,
1923
$value);
1924
continue;
1925
}
1926
1927
if ($raw_token->isQuoted()) {
1928
$is_quoted = true;
1929
$is_stemmed = false;
1930
} else {
1931
$is_quoted = false;
1932
$is_stemmed = true;
1933
}
1934
1935
$term_constraints = array();
1936
1937
$term_value = $engine->newTermsCorpus($value);
1938
1939
$parts[] = qsprintf(
1940
$conn,
1941
'IF(%T.termCorpus LIKE %~, 2, 0)',
1942
$table_alias,
1943
$term_value);
1944
1945
if ($is_stemmed) {
1946
$stem_value = $stemmer->stemToken($value);
1947
$stem_value = $engine->newTermsCorpus($stem_value);
1948
1949
$parts[] = qsprintf(
1950
$conn,
1951
'IF(%T.normalCorpus LIKE %~, 1, 0)',
1952
$table_alias,
1953
$stem_value);
1954
}
1955
}
1956
1957
$parts[] = qsprintf($conn, '%d', 0);
1958
1959
$sum = array_shift($parts);
1960
foreach ($parts as $part) {
1961
$sum = qsprintf(
1962
$conn,
1963
'%Q + %Q',
1964
$sum,
1965
$part);
1966
}
1967
1968
$select[] = qsprintf(
1969
$conn,
1970
'%Q AS %T',
1971
$sum,
1972
self::FULLTEXT_RANK);
1973
1974
// See D20297. We select these as real columns in the result set so that
1975
// constructions like this will work:
1976
//
1977
// ((SELECT ...) UNION (SELECT ...)) ORDER BY ...
1978
//
1979
// If the columns aren't part of the result set, the final "ORDER BY" can
1980
// not act on them.
1981
1982
$select[] = qsprintf(
1983
$conn,
1984
'ft_doc.epochCreated AS %T',
1985
self::FULLTEXT_CREATED);
1986
1987
$select[] = qsprintf(
1988
$conn,
1989
'ft_doc.epochModified AS %T',
1990
self::FULLTEXT_MODIFIED);
1991
1992
return $select;
1993
}
1994
1995
protected function buildFerretJoinClause(AphrontDatabaseConnection $conn) {
1996
if (!$this->ferretEngine) {
1997
return array();
1998
}
1999
2000
$op_sub = PhutilSearchQueryCompiler::OPERATOR_SUBSTRING;
2001
$op_not = PhutilSearchQueryCompiler::OPERATOR_NOT;
2002
$op_absent = PhutilSearchQueryCompiler::OPERATOR_ABSENT;
2003
$op_present = PhutilSearchQueryCompiler::OPERATOR_PRESENT;
2004
2005
$engine = $this->ferretEngine;
2006
$stemmer = $engine->newStemmer();
2007
2008
$ngram_table = $engine->getNgramsTableName();
2009
$ngram_engine = $this->getNgramEngine();
2010
2011
$flat = array();
2012
foreach ($this->ferretTokens as $fulltext_token) {
2013
$raw_token = $fulltext_token->getToken();
2014
2015
$operator = $raw_token->getOperator();
2016
2017
// If this is a negated term like "-pomegranate", don't join the ngram
2018
// table since we aren't looking for documents with this term. (We could
2019
// LEFT JOIN the table and require a NULL row, but this is probably more
2020
// trouble than it's worth.)
2021
if ($operator === $op_not) {
2022
continue;
2023
}
2024
2025
// Neither the "present" or "absent" operators benefit from joining
2026
// the ngram table.
2027
if ($operator === $op_absent || $operator === $op_present) {
2028
continue;
2029
}
2030
2031
$value = $raw_token->getValue();
2032
2033
$length = count(phutil_utf8v($value));
2034
2035
if ($raw_token->getOperator() == $op_sub) {
2036
$is_substring = true;
2037
} else {
2038
$is_substring = false;
2039
}
2040
2041
// If the user specified a substring query for a substring which is
2042
// shorter than the ngram length, we can't use the ngram index, so
2043
// don't do a join. We'll fall back to just doing LIKE on the full
2044
// corpus.
2045
if ($is_substring) {
2046
if ($length < 3) {
2047
continue;
2048
}
2049
}
2050
2051
if ($raw_token->isQuoted()) {
2052
$is_stemmed = false;
2053
} else {
2054
$is_stemmed = true;
2055
}
2056
2057
if ($is_substring) {
2058
$ngrams = $ngram_engine->getSubstringNgramsFromString($value);
2059
} else {
2060
$terms_value = $engine->newTermsCorpus($value);
2061
$ngrams = $ngram_engine->getTermNgramsFromString($terms_value);
2062
2063
// If this is a stemmed term, only look for ngrams present in both the
2064
// unstemmed and stemmed variations.
2065
if ($is_stemmed) {
2066
// Trim the boundary space characters so the stemmer recognizes this
2067
// is (or, at least, may be) a normal word and activates.
2068
$terms_value = trim($terms_value, ' ');
2069
$stem_value = $stemmer->stemToken($terms_value);
2070
$stem_ngrams = $ngram_engine->getTermNgramsFromString($stem_value);
2071
$ngrams = array_intersect($ngrams, $stem_ngrams);
2072
}
2073
}
2074
2075
foreach ($ngrams as $ngram) {
2076
$flat[] = array(
2077
'table' => $ngram_table,
2078
'ngram' => $ngram,
2079
);
2080
}
2081
}
2082
2083
// Remove common ngrams, like "the", which occur too frequently in
2084
// documents to be useful in constraining the query. The best ngrams
2085
// are obscure sequences which occur in very few documents.
2086
2087
if ($flat) {
2088
$common_ngrams = queryfx_all(
2089
$conn,
2090
'SELECT ngram FROM %T WHERE ngram IN (%Ls)',
2091
$engine->getCommonNgramsTableName(),
2092
ipull($flat, 'ngram'));
2093
$common_ngrams = ipull($common_ngrams, 'ngram', 'ngram');
2094
2095
foreach ($flat as $key => $spec) {
2096
$ngram = $spec['ngram'];
2097
if (isset($common_ngrams[$ngram])) {
2098
unset($flat[$key]);
2099
continue;
2100
}
2101
2102
// NOTE: MySQL discards trailing whitespace in CHAR(X) columns.
2103
$trim_ngram = rtrim($ngram, ' ');
2104
if (isset($common_ngrams[$trim_ngram])) {
2105
unset($flat[$key]);
2106
continue;
2107
}
2108
}
2109
}
2110
2111
// MySQL only allows us to join a maximum of 61 tables per query. Each
2112
// ngram is going to cost us a join toward that limit, so if the user
2113
// specified a very long query string, just pick 16 of the ngrams
2114
// at random.
2115
if (count($flat) > 16) {
2116
shuffle($flat);
2117
$flat = array_slice($flat, 0, 16);
2118
}
2119
2120
$alias = $this->getPrimaryTableAlias();
2121
if ($alias) {
2122
$phid_column = qsprintf($conn, '%T.%T', $alias, 'phid');
2123
} else {
2124
$phid_column = qsprintf($conn, '%T', 'phid');
2125
}
2126
2127
$document_table = $engine->getDocumentTableName();
2128
$field_table = $engine->getFieldTableName();
2129
2130
$joins = array();
2131
$joins[] = qsprintf(
2132
$conn,
2133
'JOIN %T ft_doc ON ft_doc.objectPHID = %Q',
2134
$document_table,
2135
$phid_column);
2136
2137
$idx = 1;
2138
foreach ($flat as $spec) {
2139
$table = $spec['table'];
2140
$ngram = $spec['ngram'];
2141
2142
$alias = 'ftngram_'.$idx++;
2143
2144
$joins[] = qsprintf(
2145
$conn,
2146
'JOIN %T %T ON %T.documentID = ft_doc.id AND %T.ngram = %s',
2147
$table,
2148
$alias,
2149
$alias,
2150
$alias,
2151
$ngram);
2152
}
2153
2154
$object = $this->newResultObject();
2155
if (!$object) {
2156
throw new Exception(
2157
pht(
2158
'Query class ("%s") must define "newResultObject()" to use '.
2159
'Ferret constraints.',
2160
get_class($this)));
2161
}
2162
2163
// See T13511. If we have a fulltext query which uses valid field
2164
// functions, but at least one of the functions applies to a field which
2165
// the object can never have, the query can never match anything. Detect
2166
// this and return an empty result set.
2167
2168
// (Even if the query is "field is absent" or "field does not contain
2169
// such-and-such", the interpretation is that these constraints are
2170
// not meaningful when applied to an object which can never have the
2171
// field.)
2172
2173
$functions = ipull($this->ferretTables, 'function');
2174
$functions = mpull($functions, null, 'getFerretFunctionName');
2175
foreach ($functions as $function) {
2176
if (!$function->supportsObject($object)) {
2177
throw new PhabricatorEmptyQueryException(
2178
pht(
2179
'This query uses a fulltext function which this document '.
2180
'type does not support.'));
2181
}
2182
}
2183
2184
foreach ($this->ferretTables as $table) {
2185
$alias = $table['alias'];
2186
2187
if (empty($table['optional'])) {
2188
$join_type = qsprintf($conn, 'JOIN');
2189
} else {
2190
$join_type = qsprintf($conn, 'LEFT JOIN');
2191
}
2192
2193
$joins[] = qsprintf(
2194
$conn,
2195
'%Q %T %T ON ft_doc.id = %T.documentID
2196
AND %T.fieldKey = %s',
2197
$join_type,
2198
$field_table,
2199
$alias,
2200
$alias,
2201
$alias,
2202
$table['function']->getFerretFieldKey());
2203
}
2204
2205
return $joins;
2206
}
2207
2208
protected function buildFerretWhereClause(AphrontDatabaseConnection $conn) {
2209
if (!$this->ferretEngine) {
2210
return array();
2211
}
2212
2213
$engine = $this->ferretEngine;
2214
$stemmer = $engine->newStemmer();
2215
$table_map = $this->ferretTables;
2216
2217
$op_sub = PhutilSearchQueryCompiler::OPERATOR_SUBSTRING;
2218
$op_not = PhutilSearchQueryCompiler::OPERATOR_NOT;
2219
$op_exact = PhutilSearchQueryCompiler::OPERATOR_EXACT;
2220
$op_absent = PhutilSearchQueryCompiler::OPERATOR_ABSENT;
2221
$op_present = PhutilSearchQueryCompiler::OPERATOR_PRESENT;
2222
2223
$where = array();
2224
$default_function = $engine->getDefaultFunctionKey();
2225
foreach ($this->ferretTokens as $fulltext_token) {
2226
$raw_token = $fulltext_token->getToken();
2227
$value = $raw_token->getValue();
2228
2229
$function = $raw_token->getFunction();
2230
if ($function === null) {
2231
$function = $default_function;
2232
}
2233
2234
$operator = $raw_token->getOperator();
2235
2236
$table_alias = $table_map[$function]['alias'];
2237
2238
// If this is a "field is present" operator, we've already implicitly
2239
// guaranteed this by JOINing the table. We don't need to do any
2240
// more work.
2241
$is_present = ($operator === $op_present);
2242
if ($is_present) {
2243
continue;
2244
}
2245
2246
// If this is a "field is absent" operator, we just want documents
2247
// which failed to match to a row when we LEFT JOINed the table. This
2248
// means there's no index for the field.
2249
$is_absent = ($operator === $op_absent);
2250
if ($is_absent) {
2251
$where[] = qsprintf(
2252
$conn,
2253
'(%T.rawCorpus IS NULL)',
2254
$table_alias);
2255
continue;
2256
}
2257
2258
$is_not = ($operator === $op_not);
2259
2260
if ($operator == $op_sub) {
2261
$is_substring = true;
2262
} else {
2263
$is_substring = false;
2264
}
2265
2266
// If we're doing exact search, just test the raw corpus.
2267
$is_exact = ($operator === $op_exact);
2268
if ($is_exact) {
2269
if ($is_not) {
2270
$where[] = qsprintf(
2271
$conn,
2272
'(%T.rawCorpus != %s)',
2273
$table_alias,
2274
$value);
2275
} else {
2276
$where[] = qsprintf(
2277
$conn,
2278
'(%T.rawCorpus = %s)',
2279
$table_alias,
2280
$value);
2281
}
2282
continue;
2283
}
2284
2285
// If we're doing substring search, we just match against the raw corpus
2286
// and we're done.
2287
if ($is_substring) {
2288
if ($is_not) {
2289
$where[] = qsprintf(
2290
$conn,
2291
'(%T.rawCorpus NOT LIKE %~)',
2292
$table_alias,
2293
$value);
2294
} else {
2295
$where[] = qsprintf(
2296
$conn,
2297
'(%T.rawCorpus LIKE %~)',
2298
$table_alias,
2299
$value);
2300
}
2301
continue;
2302
}
2303
2304
// Otherwise, we need to match against the term corpus and the normal
2305
// corpus, so that searching for "raw" does not find "strawberry".
2306
if ($raw_token->isQuoted()) {
2307
$is_quoted = true;
2308
$is_stemmed = false;
2309
} else {
2310
$is_quoted = false;
2311
$is_stemmed = true;
2312
}
2313
2314
// Never stem negated queries, since this can exclude results users
2315
// did not mean to exclude and generally confuse things.
2316
if ($is_not) {
2317
$is_stemmed = false;
2318
}
2319
2320
$term_constraints = array();
2321
2322
$term_value = $engine->newTermsCorpus($value);
2323
if ($is_not) {
2324
$term_constraints[] = qsprintf(
2325
$conn,
2326
'(%T.termCorpus NOT LIKE %~)',
2327
$table_alias,
2328
$term_value);
2329
} else {
2330
$term_constraints[] = qsprintf(
2331
$conn,
2332
'(%T.termCorpus LIKE %~)',
2333
$table_alias,
2334
$term_value);
2335
}
2336
2337
if ($is_stemmed) {
2338
$stem_value = $stemmer->stemToken($value);
2339
$stem_value = $engine->newTermsCorpus($stem_value);
2340
2341
$term_constraints[] = qsprintf(
2342
$conn,
2343
'(%T.normalCorpus LIKE %~)',
2344
$table_alias,
2345
$stem_value);
2346
}
2347
2348
if ($is_not) {
2349
$where[] = qsprintf(
2350
$conn,
2351
'%LA',
2352
$term_constraints);
2353
} else if ($is_quoted) {
2354
$where[] = qsprintf(
2355
$conn,
2356
'(%T.rawCorpus LIKE %~ AND %LO)',
2357
$table_alias,
2358
$value,
2359
$term_constraints);
2360
} else {
2361
$where[] = qsprintf(
2362
$conn,
2363
'%LO',
2364
$term_constraints);
2365
}
2366
}
2367
2368
if ($this->ferretQuery) {
2369
$query = $this->ferretQuery;
2370
2371
$author_phids = $query->getParameter('authorPHIDs');
2372
if ($author_phids) {
2373
$where[] = qsprintf(
2374
$conn,
2375
'ft_doc.authorPHID IN (%Ls)',
2376
$author_phids);
2377
}
2378
2379
$with_unowned = $query->getParameter('withUnowned');
2380
$with_any = $query->getParameter('withAnyOwner');
2381
2382
if ($with_any && $with_unowned) {
2383
throw new PhabricatorEmptyQueryException(
2384
pht(
2385
'This query matches only unowned documents owned by anyone, '.
2386
'which is impossible.'));
2387
}
2388
2389
$owner_phids = $query->getParameter('ownerPHIDs');
2390
if ($owner_phids && !$with_any) {
2391
if ($with_unowned) {
2392
$where[] = qsprintf(
2393
$conn,
2394
'ft_doc.ownerPHID IN (%Ls) OR ft_doc.ownerPHID IS NULL',
2395
$owner_phids);
2396
} else {
2397
$where[] = qsprintf(
2398
$conn,
2399
'ft_doc.ownerPHID IN (%Ls)',
2400
$owner_phids);
2401
}
2402
} else if ($with_unowned) {
2403
$where[] = qsprintf(
2404
$conn,
2405
'ft_doc.ownerPHID IS NULL');
2406
}
2407
2408
if ($with_any) {
2409
$where[] = qsprintf(
2410
$conn,
2411
'ft_doc.ownerPHID IS NOT NULL');
2412
}
2413
2414
$rel_open = PhabricatorSearchRelationship::RELATIONSHIP_OPEN;
2415
2416
$statuses = $query->getParameter('statuses');
2417
$is_closed = null;
2418
if ($statuses) {
2419
$statuses = array_fuse($statuses);
2420
if (count($statuses) == 1) {
2421
if (isset($statuses[$rel_open])) {
2422
$is_closed = 0;
2423
} else {
2424
$is_closed = 1;
2425
}
2426
}
2427
}
2428
2429
if ($is_closed !== null) {
2430
$where[] = qsprintf(
2431
$conn,
2432
'ft_doc.isClosed = %d',
2433
$is_closed);
2434
}
2435
}
2436
2437
return $where;
2438
}
2439
2440
protected function shouldGroupFerretResultRows() {
2441
return (bool)$this->ferretTokens;
2442
}
2443
2444
2445
/* -( Ngrams )------------------------------------------------------------- */
2446
2447
2448
protected function withNgramsConstraint(
2449
PhabricatorSearchNgrams $index,
2450
$value) {
2451
2452
if (strlen($value)) {
2453
$this->ngrams[] = array(
2454
'index' => $index,
2455
'value' => $value,
2456
'length' => count(phutil_utf8v($value)),
2457
);
2458
}
2459
2460
return $this;
2461
}
2462
2463
2464
protected function buildNgramsJoinClause(AphrontDatabaseConnection $conn) {
2465
$ngram_engine = $this->getNgramEngine();
2466
2467
$flat = array();
2468
foreach ($this->ngrams as $spec) {
2469
$length = $spec['length'];
2470
2471
if ($length < 3) {
2472
continue;
2473
}
2474
2475
$index = $spec['index'];
2476
$value = $spec['value'];
2477
2478
$ngrams = $ngram_engine->getSubstringNgramsFromString($value);
2479
2480
foreach ($ngrams as $ngram) {
2481
$flat[] = array(
2482
'table' => $index->getTableName(),
2483
'ngram' => $ngram,
2484
);
2485
}
2486
}
2487
2488
if (!$flat) {
2489
return array();
2490
}
2491
2492
// MySQL only allows us to join a maximum of 61 tables per query. Each
2493
// ngram is going to cost us a join toward that limit, so if the user
2494
// specified a very long query string, just pick 16 of the ngrams
2495
// at random.
2496
if (count($flat) > 16) {
2497
shuffle($flat);
2498
$flat = array_slice($flat, 0, 16);
2499
}
2500
2501
$alias = $this->getPrimaryTableAlias();
2502
if ($alias) {
2503
$id_column = qsprintf($conn, '%T.%T', $alias, 'id');
2504
} else {
2505
$id_column = qsprintf($conn, '%T', 'id');
2506
}
2507
2508
$idx = 1;
2509
$joins = array();
2510
foreach ($flat as $spec) {
2511
$table = $spec['table'];
2512
$ngram = $spec['ngram'];
2513
2514
$alias = 'ngm'.$idx++;
2515
2516
$joins[] = qsprintf(
2517
$conn,
2518
'JOIN %T %T ON %T.objectID = %Q AND %T.ngram = %s',
2519
$table,
2520
$alias,
2521
$alias,
2522
$id_column,
2523
$alias,
2524
$ngram);
2525
}
2526
2527
return $joins;
2528
}
2529
2530
2531
protected function buildNgramsWhereClause(AphrontDatabaseConnection $conn) {
2532
$where = array();
2533
2534
$ngram_engine = $this->getNgramEngine();
2535
2536
foreach ($this->ngrams as $ngram) {
2537
$index = $ngram['index'];
2538
$value = $ngram['value'];
2539
2540
$column = $index->getColumnName();
2541
$alias = $this->getPrimaryTableAlias();
2542
if ($alias) {
2543
$column = qsprintf($conn, '%T.%T', $alias, $column);
2544
} else {
2545
$column = qsprintf($conn, '%T', $column);
2546
}
2547
2548
$tokens = $ngram_engine->tokenizeNgramString($value);
2549
2550
foreach ($tokens as $token) {
2551
$where[] = qsprintf(
2552
$conn,
2553
'%Q LIKE %~',
2554
$column,
2555
$token);
2556
}
2557
}
2558
2559
return $where;
2560
}
2561
2562
2563
protected function shouldGroupNgramResultRows() {
2564
return (bool)$this->ngrams;
2565
}
2566
2567
private function getNgramEngine() {
2568
if (!$this->ngramEngine) {
2569
$this->ngramEngine = new PhabricatorSearchNgramEngine();
2570
}
2571
2572
return $this->ngramEngine;
2573
}
2574
2575
2576
/* -( Edge Logic )--------------------------------------------------------- */
2577
2578
2579
/**
2580
* Convenience method for specifying edge logic constraints with a list of
2581
* PHIDs.
2582
*
2583
* @param const Edge constant.
2584
* @param const Constraint operator.
2585
* @param list<phid> List of PHIDs.
2586
* @return this
2587
* @task edgelogic
2588
*/
2589
public function withEdgeLogicPHIDs($edge_type, $operator, array $phids) {
2590
$constraints = array();
2591
foreach ($phids as $phid) {
2592
$constraints[] = new PhabricatorQueryConstraint($operator, $phid);
2593
}
2594
2595
return $this->withEdgeLogicConstraints($edge_type, $constraints);
2596
}
2597
2598
2599
/**
2600
* @return this
2601
* @task edgelogic
2602
*/
2603
public function withEdgeLogicConstraints($edge_type, array $constraints) {
2604
assert_instances_of($constraints, 'PhabricatorQueryConstraint');
2605
2606
$constraints = mgroup($constraints, 'getOperator');
2607
foreach ($constraints as $operator => $list) {
2608
foreach ($list as $item) {
2609
$this->edgeLogicConstraints[$edge_type][$operator][] = $item;
2610
}
2611
}
2612
2613
$this->edgeLogicConstraintsAreValid = false;
2614
2615
return $this;
2616
}
2617
2618
2619
/**
2620
* @task edgelogic
2621
*/
2622
public function buildEdgeLogicSelectClause(AphrontDatabaseConnection $conn) {
2623
$select = array();
2624
2625
$this->validateEdgeLogicConstraints();
2626
2627
foreach ($this->edgeLogicConstraints as $type => $constraints) {
2628
foreach ($constraints as $operator => $list) {
2629
$alias = $this->getEdgeLogicTableAlias($operator, $type);
2630
switch ($operator) {
2631
case PhabricatorQueryConstraint::OPERATOR_AND:
2632
if (count($list) > 1) {
2633
$select[] = qsprintf(
2634
$conn,
2635
'COUNT(DISTINCT(%T.dst)) %T',
2636
$alias,
2637
$this->buildEdgeLogicTableAliasCount($alias));
2638
}
2639
break;
2640
case PhabricatorQueryConstraint::OPERATOR_ANCESTOR:
2641
// This is tricky. We have a query which specifies multiple
2642
// projects, each of which may have an arbitrarily large number
2643
// of descendants.
2644
2645
// Suppose the projects are "Engineering" and "Operations", and
2646
// "Engineering" has subprojects X, Y and Z.
2647
2648
// We first use `FIELD(dst, X, Y, Z)` to produce a 0 if a row
2649
// is not part of Engineering at all, or some number other than
2650
// 0 if it is.
2651
2652
// Then we use `IF(..., idx, NULL)` to convert the 0 to a NULL and
2653
// any other value to an index (say, 1) for the ancestor.
2654
2655
// We build these up for every ancestor, then use `COALESCE(...)`
2656
// to select the non-null one, giving us an ancestor which this
2657
// row is a member of.
2658
2659
// From there, we use `COUNT(DISTINCT(...))` to make sure that
2660
// each result row is a member of all ancestors.
2661
if (count($list) > 1) {
2662
$idx = 1;
2663
$parts = array();
2664
foreach ($list as $constraint) {
2665
$parts[] = qsprintf(
2666
$conn,
2667
'IF(FIELD(%T.dst, %Ls) != 0, %d, NULL)',
2668
$alias,
2669
(array)$constraint->getValue(),
2670
$idx++);
2671
}
2672
$parts = qsprintf($conn, '%LQ', $parts);
2673
2674
$select[] = qsprintf(
2675
$conn,
2676
'COUNT(DISTINCT(COALESCE(%Q))) %T',
2677
$parts,
2678
$this->buildEdgeLogicTableAliasAncestor($alias));
2679
}
2680
break;
2681
default:
2682
break;
2683
}
2684
}
2685
}
2686
2687
return $select;
2688
}
2689
2690
2691
/**
2692
* @task edgelogic
2693
*/
2694
public function buildEdgeLogicJoinClause(AphrontDatabaseConnection $conn) {
2695
$edge_table = PhabricatorEdgeConfig::TABLE_NAME_EDGE;
2696
$phid_column = $this->getApplicationSearchObjectPHIDColumn($conn);
2697
2698
$joins = array();
2699
foreach ($this->edgeLogicConstraints as $type => $constraints) {
2700
2701
$op_null = PhabricatorQueryConstraint::OPERATOR_NULL;
2702
$has_null = isset($constraints[$op_null]);
2703
2704
// If we're going to process an only() operator, build a list of the
2705
// acceptable set of PHIDs first. We'll only match results which have
2706
// no edges to any other PHIDs.
2707
$all_phids = array();
2708
if (isset($constraints[PhabricatorQueryConstraint::OPERATOR_ONLY])) {
2709
foreach ($constraints as $operator => $list) {
2710
switch ($operator) {
2711
case PhabricatorQueryConstraint::OPERATOR_ANCESTOR:
2712
case PhabricatorQueryConstraint::OPERATOR_AND:
2713
case PhabricatorQueryConstraint::OPERATOR_OR:
2714
foreach ($list as $constraint) {
2715
$value = (array)$constraint->getValue();
2716
foreach ($value as $v) {
2717
$all_phids[$v] = $v;
2718
}
2719
}
2720
break;
2721
}
2722
}
2723
}
2724
2725
foreach ($constraints as $operator => $list) {
2726
$alias = $this->getEdgeLogicTableAlias($operator, $type);
2727
2728
$phids = array();
2729
foreach ($list as $constraint) {
2730
$value = (array)$constraint->getValue();
2731
foreach ($value as $v) {
2732
$phids[$v] = $v;
2733
}
2734
}
2735
$phids = array_keys($phids);
2736
2737
switch ($operator) {
2738
case PhabricatorQueryConstraint::OPERATOR_NOT:
2739
$joins[] = qsprintf(
2740
$conn,
2741
'LEFT JOIN %T %T ON %Q = %T.src AND %T.type = %d
2742
AND %T.dst IN (%Ls)',
2743
$edge_table,
2744
$alias,
2745
$phid_column,
2746
$alias,
2747
$alias,
2748
$type,
2749
$alias,
2750
$phids);
2751
break;
2752
case PhabricatorQueryConstraint::OPERATOR_ANCESTOR:
2753
case PhabricatorQueryConstraint::OPERATOR_AND:
2754
case PhabricatorQueryConstraint::OPERATOR_OR:
2755
// If we're including results with no matches, we have to degrade
2756
// this to a LEFT join. We'll use WHERE to select matching rows
2757
// later.
2758
if ($has_null) {
2759
$join_type = qsprintf($conn, 'LEFT');
2760
} else {
2761
$join_type = qsprintf($conn, '');
2762
}
2763
2764
$joins[] = qsprintf(
2765
$conn,
2766
'%Q JOIN %T %T ON %Q = %T.src AND %T.type = %d
2767
AND %T.dst IN (%Ls)',
2768
$join_type,
2769
$edge_table,
2770
$alias,
2771
$phid_column,
2772
$alias,
2773
$alias,
2774
$type,
2775
$alias,
2776
$phids);
2777
break;
2778
case PhabricatorQueryConstraint::OPERATOR_NULL:
2779
$joins[] = qsprintf(
2780
$conn,
2781
'LEFT JOIN %T %T ON %Q = %T.src AND %T.type = %d',
2782
$edge_table,
2783
$alias,
2784
$phid_column,
2785
$alias,
2786
$alias,
2787
$type);
2788
break;
2789
case PhabricatorQueryConstraint::OPERATOR_ONLY:
2790
$joins[] = qsprintf(
2791
$conn,
2792
'LEFT JOIN %T %T ON %Q = %T.src AND %T.type = %d
2793
AND %T.dst NOT IN (%Ls)',
2794
$edge_table,
2795
$alias,
2796
$phid_column,
2797
$alias,
2798
$alias,
2799
$type,
2800
$alias,
2801
$all_phids);
2802
break;
2803
}
2804
}
2805
}
2806
2807
return $joins;
2808
}
2809
2810
2811
/**
2812
* @task edgelogic
2813
*/
2814
public function buildEdgeLogicWhereClause(AphrontDatabaseConnection $conn) {
2815
$where = array();
2816
2817
foreach ($this->edgeLogicConstraints as $type => $constraints) {
2818
2819
$full = array();
2820
$null = array();
2821
2822
$op_null = PhabricatorQueryConstraint::OPERATOR_NULL;
2823
$has_null = isset($constraints[$op_null]);
2824
2825
foreach ($constraints as $operator => $list) {
2826
$alias = $this->getEdgeLogicTableAlias($operator, $type);
2827
switch ($operator) {
2828
case PhabricatorQueryConstraint::OPERATOR_NOT:
2829
case PhabricatorQueryConstraint::OPERATOR_ONLY:
2830
$full[] = qsprintf(
2831
$conn,
2832
'%T.dst IS NULL',
2833
$alias);
2834
break;
2835
case PhabricatorQueryConstraint::OPERATOR_AND:
2836
case PhabricatorQueryConstraint::OPERATOR_OR:
2837
if ($has_null) {
2838
$full[] = qsprintf(
2839
$conn,
2840
'%T.dst IS NOT NULL',
2841
$alias);
2842
}
2843
break;
2844
case PhabricatorQueryConstraint::OPERATOR_NULL:
2845
$null[] = qsprintf(
2846
$conn,
2847
'%T.dst IS NULL',
2848
$alias);
2849
break;
2850
}
2851
}
2852
2853
if ($full && $null) {
2854
$where[] = qsprintf($conn, '(%LA OR %LA)', $full, $null);
2855
} else if ($full) {
2856
foreach ($full as $condition) {
2857
$where[] = $condition;
2858
}
2859
} else if ($null) {
2860
foreach ($null as $condition) {
2861
$where[] = $condition;
2862
}
2863
}
2864
}
2865
2866
return $where;
2867
}
2868
2869
2870
/**
2871
* @task edgelogic
2872
*/
2873
public function buildEdgeLogicHavingClause(AphrontDatabaseConnection $conn) {
2874
$having = array();
2875
2876
foreach ($this->edgeLogicConstraints as $type => $constraints) {
2877
foreach ($constraints as $operator => $list) {
2878
$alias = $this->getEdgeLogicTableAlias($operator, $type);
2879
switch ($operator) {
2880
case PhabricatorQueryConstraint::OPERATOR_AND:
2881
if (count($list) > 1) {
2882
$having[] = qsprintf(
2883
$conn,
2884
'%T = %d',
2885
$this->buildEdgeLogicTableAliasCount($alias),
2886
count($list));
2887
}
2888
break;
2889
case PhabricatorQueryConstraint::OPERATOR_ANCESTOR:
2890
if (count($list) > 1) {
2891
$having[] = qsprintf(
2892
$conn,
2893
'%T = %d',
2894
$this->buildEdgeLogicTableAliasAncestor($alias),
2895
count($list));
2896
}
2897
break;
2898
}
2899
}
2900
}
2901
2902
return $having;
2903
}
2904
2905
2906
/**
2907
* @task edgelogic
2908
*/
2909
public function shouldGroupEdgeLogicResultRows() {
2910
foreach ($this->edgeLogicConstraints as $type => $constraints) {
2911
foreach ($constraints as $operator => $list) {
2912
switch ($operator) {
2913
case PhabricatorQueryConstraint::OPERATOR_NOT:
2914
case PhabricatorQueryConstraint::OPERATOR_AND:
2915
case PhabricatorQueryConstraint::OPERATOR_OR:
2916
if (count($list) > 1) {
2917
return true;
2918
}
2919
break;
2920
case PhabricatorQueryConstraint::OPERATOR_ANCESTOR:
2921
// NOTE: We must always group query results rows when using an
2922
// "ANCESTOR" operator because a single task may be related to
2923
// two different descendants of a particular ancestor. For
2924
// discussion, see T12753.
2925
return true;
2926
case PhabricatorQueryConstraint::OPERATOR_NULL:
2927
case PhabricatorQueryConstraint::OPERATOR_ONLY:
2928
return true;
2929
}
2930
}
2931
}
2932
2933
return false;
2934
}
2935
2936
2937
/**
2938
* @task edgelogic
2939
*/
2940
private function getEdgeLogicTableAlias($operator, $type) {
2941
return 'edgelogic_'.$operator.'_'.$type;
2942
}
2943
2944
2945
/**
2946
* @task edgelogic
2947
*/
2948
private function buildEdgeLogicTableAliasCount($alias) {
2949
return $alias.'_count';
2950
}
2951
2952
/**
2953
* @task edgelogic
2954
*/
2955
private function buildEdgeLogicTableAliasAncestor($alias) {
2956
return $alias.'_ancestor';
2957
}
2958
2959
2960
/**
2961
* Select certain edge logic constraint values.
2962
*
2963
* @task edgelogic
2964
*/
2965
protected function getEdgeLogicValues(
2966
array $edge_types,
2967
array $operators) {
2968
2969
$values = array();
2970
2971
$constraint_lists = $this->edgeLogicConstraints;
2972
if ($edge_types) {
2973
$constraint_lists = array_select_keys($constraint_lists, $edge_types);
2974
}
2975
2976
foreach ($constraint_lists as $type => $constraints) {
2977
if ($operators) {
2978
$constraints = array_select_keys($constraints, $operators);
2979
}
2980
foreach ($constraints as $operator => $list) {
2981
foreach ($list as $constraint) {
2982
$value = (array)$constraint->getValue();
2983
foreach ($value as $v) {
2984
$values[] = $v;
2985
}
2986
}
2987
}
2988
}
2989
2990
return $values;
2991
}
2992
2993
2994
/**
2995
* Validate edge logic constraints for the query.
2996
*
2997
* @return this
2998
* @task edgelogic
2999
*/
3000
private function validateEdgeLogicConstraints() {
3001
if ($this->edgeLogicConstraintsAreValid) {
3002
return $this;
3003
}
3004
3005
foreach ($this->edgeLogicConstraints as $type => $constraints) {
3006
foreach ($constraints as $operator => $list) {
3007
switch ($operator) {
3008
case PhabricatorQueryConstraint::OPERATOR_EMPTY:
3009
throw new PhabricatorEmptyQueryException(
3010
pht('This query specifies an empty constraint.'));
3011
}
3012
}
3013
}
3014
3015
// This should probably be more modular, eventually, but we only do
3016
// project-based edge logic today.
3017
3018
$project_phids = $this->getEdgeLogicValues(
3019
array(
3020
PhabricatorProjectObjectHasProjectEdgeType::EDGECONST,
3021
),
3022
array(
3023
PhabricatorQueryConstraint::OPERATOR_AND,
3024
PhabricatorQueryConstraint::OPERATOR_OR,
3025
PhabricatorQueryConstraint::OPERATOR_NOT,
3026
PhabricatorQueryConstraint::OPERATOR_ANCESTOR,
3027
));
3028
if ($project_phids) {
3029
$projects = id(new PhabricatorProjectQuery())
3030
->setViewer($this->getViewer())
3031
->setParentQuery($this)
3032
->withPHIDs($project_phids)
3033
->execute();
3034
$projects = mpull($projects, null, 'getPHID');
3035
foreach ($project_phids as $phid) {
3036
if (empty($projects[$phid])) {
3037
throw new PhabricatorEmptyQueryException(
3038
pht(
3039
'This query is constrained by a project you do not have '.
3040
'permission to see.'));
3041
}
3042
}
3043
}
3044
3045
$op_and = PhabricatorQueryConstraint::OPERATOR_AND;
3046
$op_or = PhabricatorQueryConstraint::OPERATOR_OR;
3047
$op_ancestor = PhabricatorQueryConstraint::OPERATOR_ANCESTOR;
3048
3049
foreach ($this->edgeLogicConstraints as $type => $constraints) {
3050
foreach ($constraints as $operator => $list) {
3051
switch ($operator) {
3052
case PhabricatorQueryConstraint::OPERATOR_ONLY:
3053
if (count($list) > 1) {
3054
throw new PhabricatorEmptyQueryException(
3055
pht(
3056
'This query specifies only() more than once.'));
3057
}
3058
3059
$have_and = idx($constraints, $op_and);
3060
$have_or = idx($constraints, $op_or);
3061
$have_ancestor = idx($constraints, $op_ancestor);
3062
if (!$have_and && !$have_or && !$have_ancestor) {
3063
throw new PhabricatorEmptyQueryException(
3064
pht(
3065
'This query specifies only(), but no other constraints '.
3066
'which it can apply to.'));
3067
}
3068
break;
3069
}
3070
}
3071
}
3072
3073
$this->edgeLogicConstraintsAreValid = true;
3074
3075
return $this;
3076
}
3077
3078
3079
/* -( Spaces )------------------------------------------------------------- */
3080
3081
3082
/**
3083
* Constrain the query to return results from only specific Spaces.
3084
*
3085
* Pass a list of Space PHIDs, or `null` to represent the default space. Only
3086
* results in those Spaces will be returned.
3087
*
3088
* Queries are always constrained to include only results from spaces the
3089
* viewer has access to.
3090
*
3091
* @param list<phid|null>
3092
* @task spaces
3093
*/
3094
public function withSpacePHIDs(array $space_phids) {
3095
$object = $this->newResultObject();
3096
3097
if (!$object) {
3098
throw new Exception(
3099
pht(
3100
'This query (of class "%s") does not implement newResultObject(), '.
3101
'but must implement this method to enable support for Spaces.',
3102
get_class($this)));
3103
}
3104
3105
if (!($object instanceof PhabricatorSpacesInterface)) {
3106
throw new Exception(
3107
pht(
3108
'This query (of class "%s") returned an object of class "%s" from '.
3109
'getNewResultObject(), but it does not implement the required '.
3110
'interface ("%s"). Objects must implement this interface to enable '.
3111
'Spaces support.',
3112
get_class($this),
3113
get_class($object),
3114
'PhabricatorSpacesInterface'));
3115
}
3116
3117
$this->spacePHIDs = $space_phids;
3118
3119
return $this;
3120
}
3121
3122
public function withSpaceIsArchived($archived) {
3123
$this->spaceIsArchived = $archived;
3124
return $this;
3125
}
3126
3127
3128
/**
3129
* Constrain the query to include only results in valid Spaces.
3130
*
3131
* This method builds part of a WHERE clause which considers the spaces the
3132
* viewer has access to see with any explicit constraint on spaces added by
3133
* @{method:withSpacePHIDs}.
3134
*
3135
* @param AphrontDatabaseConnection Database connection.
3136
* @return string Part of a WHERE clause.
3137
* @task spaces
3138
*/
3139
private function buildSpacesWhereClause(AphrontDatabaseConnection $conn) {
3140
$object = $this->newResultObject();
3141
if (!$object) {
3142
return null;
3143
}
3144
3145
if (!($object instanceof PhabricatorSpacesInterface)) {
3146
return null;
3147
}
3148
3149
$viewer = $this->getViewer();
3150
3151
// If we have an omnipotent viewer and no formal space constraints, don't
3152
// emit a clause. This primarily enables older migrations to run cleanly,
3153
// without fataling because they try to match a `spacePHID` column which
3154
// does not exist yet. See T8743, T8746.
3155
if ($viewer->isOmnipotent()) {
3156
if ($this->spaceIsArchived === null && $this->spacePHIDs === null) {
3157
return null;
3158
}
3159
}
3160
3161
// See T13240. If this query raises policy exceptions, don't filter objects
3162
// in the MySQL layer. We want them to reach the application layer so we
3163
// can reject them and raise an exception.
3164
if ($this->shouldRaisePolicyExceptions()) {
3165
return null;
3166
}
3167
3168
$space_phids = array();
3169
$include_null = false;
3170
3171
$all = PhabricatorSpacesNamespaceQuery::getAllSpaces();
3172
if (!$all) {
3173
// If there are no spaces at all, implicitly give the viewer access to
3174
// the default space.
3175
$include_null = true;
3176
} else {
3177
// Otherwise, give them access to the spaces they have permission to
3178
// see.
3179
$viewer_spaces = PhabricatorSpacesNamespaceQuery::getViewerSpaces(
3180
$viewer);
3181
foreach ($viewer_spaces as $viewer_space) {
3182
if ($this->spaceIsArchived !== null) {
3183
if ($viewer_space->getIsArchived() != $this->spaceIsArchived) {
3184
continue;
3185
}
3186
}
3187
$phid = $viewer_space->getPHID();
3188
$space_phids[$phid] = $phid;
3189
if ($viewer_space->getIsDefaultNamespace()) {
3190
$include_null = true;
3191
}
3192
}
3193
}
3194
3195
// If we have additional explicit constraints, evaluate them now.
3196
if ($this->spacePHIDs !== null) {
3197
$explicit = array();
3198
$explicit_null = false;
3199
foreach ($this->spacePHIDs as $phid) {
3200
if ($phid === null) {
3201
$space = PhabricatorSpacesNamespaceQuery::getDefaultSpace();
3202
} else {
3203
$space = idx($all, $phid);
3204
}
3205
3206
if ($space) {
3207
$phid = $space->getPHID();
3208
$explicit[$phid] = $phid;
3209
if ($space->getIsDefaultNamespace()) {
3210
$explicit_null = true;
3211
}
3212
}
3213
}
3214
3215
// If the viewer can see the default space but it isn't on the explicit
3216
// list of spaces to query, don't match it.
3217
if ($include_null && !$explicit_null) {
3218
$include_null = false;
3219
}
3220
3221
// Include only the spaces common to the viewer and the constraints.
3222
$space_phids = array_intersect_key($space_phids, $explicit);
3223
}
3224
3225
if (!$space_phids && !$include_null) {
3226
if ($this->spacePHIDs === null) {
3227
throw new PhabricatorEmptyQueryException(
3228
pht('You do not have access to any spaces.'));
3229
} else {
3230
throw new PhabricatorEmptyQueryException(
3231
pht(
3232
'You do not have access to any of the spaces this query '.
3233
'is constrained to.'));
3234
}
3235
}
3236
3237
$alias = $this->getPrimaryTableAlias();
3238
if ($alias) {
3239
$col = qsprintf($conn, '%T.spacePHID', $alias);
3240
} else {
3241
$col = qsprintf($conn, 'spacePHID');
3242
}
3243
3244
if ($space_phids && $include_null) {
3245
return qsprintf(
3246
$conn,
3247
'(%Q IN (%Ls) OR %Q IS NULL)',
3248
$col,
3249
$space_phids,
3250
$col);
3251
} else if ($space_phids) {
3252
return qsprintf(
3253
$conn,
3254
'%Q IN (%Ls)',
3255
$col,
3256
$space_phids);
3257
} else {
3258
return qsprintf(
3259
$conn,
3260
'%Q IS NULL',
3261
$col);
3262
}
3263
}
3264
3265
private function hasFerretOrder() {
3266
$vector = $this->getOrderVector();
3267
3268
if ($vector->containsKey('rank')) {
3269
return true;
3270
}
3271
3272
if ($vector->containsKey('fulltext-created')) {
3273
return true;
3274
}
3275
3276
if ($vector->containsKey('fulltext-modified')) {
3277
return true;
3278
}
3279
3280
return false;
3281
}
3282
3283
}
3284
3285