[ Index ] |
PHP Cross Reference of Phabricator |
[Summary view] [Print] [Text view]
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 appsearch Integration with ApplicationSearch 8 */ 9 abstract class PhabricatorCursorPagedPolicyAwareQuery 10 extends PhabricatorPolicyAwareQuery { 11 12 private $afterID; 13 private $beforeID; 14 private $applicationSearchConstraints = array(); 15 private $applicationSearchOrders = array(); 16 private $internalPaging; 17 18 protected function getPagingColumn() { 19 return 'id'; 20 } 21 22 protected function getPagingValue($result) { 23 if (!is_object($result)) { 24 // This interface can't be typehinted and PHP gets really angry if we 25 // call a method on a non-object, so add an explicit check here. 26 throw new Exception(pht('Expected object, got "%s"!', gettype($result))); 27 } 28 return $result->getID(); 29 } 30 31 protected function getReversePaging() { 32 return false; 33 } 34 35 protected function nextPage(array $page) { 36 // See getPagingViewer() for a description of this flag. 37 $this->internalPaging = true; 38 39 if ($this->beforeID) { 40 $this->beforeID = $this->getPagingValue(last($page)); 41 } else { 42 $this->afterID = $this->getPagingValue(last($page)); 43 } 44 } 45 46 final public function setAfterID($object_id) { 47 $this->afterID = $object_id; 48 return $this; 49 } 50 51 final protected function getAfterID() { 52 return $this->afterID; 53 } 54 55 final public function setBeforeID($object_id) { 56 $this->beforeID = $object_id; 57 return $this; 58 } 59 60 final protected function getBeforeID() { 61 return $this->beforeID; 62 } 63 64 65 /** 66 * Get the viewer for making cursor paging queries. 67 * 68 * NOTE: You should ONLY use this viewer to load cursor objects while 69 * building paging queries. 70 * 71 * Cursor paging can happen in two ways. First, the user can request a page 72 * like `/stuff/?after=33`, which explicitly causes paging. Otherwise, we 73 * can fall back to implicit paging if we filter some results out of a 74 * result list because the user can't see them and need to go fetch some more 75 * results to generate a large enough result list. 76 * 77 * In the first case, want to use the viewer's policies to load the object. 78 * This prevents an attacker from figuring out information about an object 79 * they can't see by executing queries like `/stuff/?after=33&order=name`, 80 * which would otherwise give them a hint about the name of the object. 81 * Generally, if a user can't see an object, they can't use it to page. 82 * 83 * In the second case, we need to load the object whether the user can see 84 * it or not, because we need to examine new results. For example, if a user 85 * loads `/stuff/` and we run a query for the first 100 items that they can 86 * see, but the first 100 rows in the database aren't visible, we need to 87 * be able to issue a query for the next 100 results. If we can't load the 88 * cursor object, we'll fail or issue the same query over and over again. 89 * So, generally, internal paging must bypass policy controls. 90 * 91 * This method returns the appropriate viewer, based on the context in which 92 * the paging is occuring. 93 * 94 * @return PhabricatorUser Viewer for executing paging queries. 95 */ 96 final protected function getPagingViewer() { 97 if ($this->internalPaging) { 98 return PhabricatorUser::getOmnipotentUser(); 99 } else { 100 return $this->getViewer(); 101 } 102 } 103 104 final protected function buildLimitClause(AphrontDatabaseConnection $conn_r) { 105 if ($this->getRawResultLimit()) { 106 return qsprintf($conn_r, 'LIMIT %d', $this->getRawResultLimit()); 107 } else { 108 return ''; 109 } 110 } 111 112 protected function buildPagingClause( 113 AphrontDatabaseConnection $conn_r) { 114 115 if ($this->beforeID) { 116 return qsprintf( 117 $conn_r, 118 '%Q %Q %s', 119 $this->getPagingColumn(), 120 $this->getReversePaging() ? '<' : '>', 121 $this->beforeID); 122 } else if ($this->afterID) { 123 return qsprintf( 124 $conn_r, 125 '%Q %Q %s', 126 $this->getPagingColumn(), 127 $this->getReversePaging() ? '>' : '<', 128 $this->afterID); 129 } 130 131 return null; 132 } 133 134 final protected function buildOrderClause(AphrontDatabaseConnection $conn_r) { 135 if ($this->beforeID) { 136 return qsprintf( 137 $conn_r, 138 'ORDER BY %Q %Q', 139 $this->getPagingColumn(), 140 $this->getReversePaging() ? 'DESC' : 'ASC'); 141 } else { 142 return qsprintf( 143 $conn_r, 144 'ORDER BY %Q %Q', 145 $this->getPagingColumn(), 146 $this->getReversePaging() ? 'ASC' : 'DESC'); 147 } 148 } 149 150 final protected function didLoadResults(array $results) { 151 if ($this->beforeID) { 152 $results = array_reverse($results, $preserve_keys = true); 153 } 154 return $results; 155 } 156 157 final public function executeWithCursorPager(AphrontCursorPagerView $pager) { 158 $this->setLimit($pager->getPageSize() + 1); 159 160 if ($pager->getAfterID()) { 161 $this->setAfterID($pager->getAfterID()); 162 } else if ($pager->getBeforeID()) { 163 $this->setBeforeID($pager->getBeforeID()); 164 } 165 166 $results = $this->execute(); 167 168 $sliced_results = $pager->sliceResults($results); 169 170 if ($sliced_results) { 171 if ($pager->getBeforeID() || (count($results) > $pager->getPageSize())) { 172 $pager->setNextPageID($this->getPagingValue(last($sliced_results))); 173 } 174 175 if ($pager->getAfterID() || 176 ($pager->getBeforeID() && (count($results) > $pager->getPageSize()))) { 177 $pager->setPrevPageID($this->getPagingValue(head($sliced_results))); 178 } 179 } 180 181 return $sliced_results; 182 } 183 184 185 /** 186 * Simplifies the task of constructing a paging clause across multiple 187 * columns. In the general case, this looks like: 188 * 189 * A > a OR (A = a AND B > b) OR (A = a AND B = b AND C > c) 190 * 191 * To build a clause, specify the name, type, and value of each column 192 * to include: 193 * 194 * $this->buildPagingClauseFromMultipleColumns( 195 * $conn_r, 196 * array( 197 * array( 198 * 'name' => 'title', 199 * 'type' => 'string', 200 * 'value' => $cursor->getTitle(), 201 * 'reverse' => true, 202 * ), 203 * array( 204 * 'name' => 'id', 205 * 'type' => 'int', 206 * 'value' => $cursor->getID(), 207 * ), 208 * ), 209 * array( 210 * 'reversed' => $is_reversed, 211 * )); 212 * 213 * This method will then return a composable clause for inclusion in WHERE. 214 * 215 * @param AphrontDatabaseConnection Connection query will execute on. 216 * @param list<map> Column description dictionaries. 217 * @param map Additional constuction options. 218 * @return string Query clause. 219 */ 220 final protected function buildPagingClauseFromMultipleColumns( 221 AphrontDatabaseConnection $conn, 222 array $columns, 223 array $options) { 224 225 foreach ($columns as $column) { 226 PhutilTypeSpec::checkMap( 227 $column, 228 array( 229 'name' => 'string', 230 'value' => 'wild', 231 'type' => 'string', 232 'reverse' => 'optional bool', 233 )); 234 } 235 236 PhutilTypeSpec::checkMap( 237 $options, 238 array( 239 'reversed' => 'optional bool', 240 )); 241 242 $is_query_reversed = idx($options, 'reversed', false); 243 244 $clauses = array(); 245 $accumulated = array(); 246 $last_key = last_key($columns); 247 foreach ($columns as $key => $column) { 248 $name = $column['name']; 249 250 $type = $column['type']; 251 switch ($type) { 252 case 'int': 253 $value = qsprintf($conn, '%d', $column['value']); 254 break; 255 case 'string': 256 $value = qsprintf($conn, '%s', $column['value']); 257 break; 258 default: 259 throw new Exception("Unknown column type '{$type}'!"); 260 } 261 262 $is_column_reversed = idx($column, 'reverse', false); 263 $reverse = ($is_query_reversed xor $is_column_reversed); 264 265 $clause = $accumulated; 266 $clause[] = qsprintf( 267 $conn, 268 '%Q %Q %Q', 269 $name, 270 $reverse ? '>' : '<', 271 $value); 272 $clauses[] = '('.implode(') AND (', $clause).')'; 273 274 $accumulated[] = qsprintf( 275 $conn, 276 '%Q = %Q', 277 $name, 278 $value); 279 } 280 281 return '('.implode(') OR (', $clauses).')'; 282 } 283 284 285 /* -( Application Search )------------------------------------------------- */ 286 287 288 /** 289 * Constrain the query with an ApplicationSearch index, requiring field values 290 * contain at least one of the values in a set. 291 * 292 * This constraint can build the most common types of queries, like: 293 * 294 * - Find users with shirt sizes "X" or "XL". 295 * - Find shoes with size "13". 296 * 297 * @param PhabricatorCustomFieldIndexStorage Table where the index is stored. 298 * @param string|list<string> One or more values to filter by. 299 * @return this 300 * @task appsearch 301 */ 302 public function withApplicationSearchContainsConstraint( 303 PhabricatorCustomFieldIndexStorage $index, 304 $value) { 305 306 $this->applicationSearchConstraints[] = array( 307 'type' => $index->getIndexValueType(), 308 'cond' => '=', 309 'table' => $index->getTableName(), 310 'index' => $index->getIndexKey(), 311 'value' => $value, 312 ); 313 314 return $this; 315 } 316 317 318 /** 319 * Constrain the query with an ApplicationSearch index, requiring values 320 * exist in a given range. 321 * 322 * This constraint is useful for expressing date ranges: 323 * 324 * - Find events between July 1st and July 7th. 325 * 326 * The ends of the range are inclusive, so a `$min` of `3` and a `$max` of 327 * `5` will match fields with values `3`, `4`, or `5`. Providing `null` for 328 * either end of the range will leave that end of the constraint open. 329 * 330 * @param PhabricatorCustomFieldIndexStorage Table where the index is stored. 331 * @param int|null Minimum permissible value, inclusive. 332 * @param int|null Maximum permissible value, inclusive. 333 * @return this 334 * @task appsearch 335 */ 336 public function withApplicationSearchRangeConstraint( 337 PhabricatorCustomFieldIndexStorage $index, 338 $min, 339 $max) { 340 341 $index_type = $index->getIndexValueType(); 342 if ($index_type != 'int') { 343 throw new Exception( 344 pht( 345 'Attempting to apply a range constraint to a field with index type '. 346 '"%s", expected type "%s".', 347 $index_type, 348 'int')); 349 } 350 351 $this->applicationSearchConstraints[] = array( 352 'type' => $index->getIndexValueType(), 353 'cond' => 'range', 354 'table' => $index->getTableName(), 355 'index' => $index->getIndexKey(), 356 'value' => array($min, $max), 357 ); 358 359 return $this; 360 } 361 362 363 /** 364 * Order the results by an ApplicationSearch index. 365 * 366 * @param PhabricatorCustomField Field to which the index belongs. 367 * @param PhabricatorCustomFieldIndexStorage Table where the index is stored. 368 * @param bool True to sort ascending. 369 * @return this 370 * @task appsearch 371 */ 372 public function withApplicationSearchOrder( 373 PhabricatorCustomField $field, 374 PhabricatorCustomFieldIndexStorage $index, 375 $ascending) { 376 377 $this->applicationSearchOrders[] = array( 378 'key' => $field->getFieldKey(), 379 'type' => $index->getIndexValueType(), 380 'table' => $index->getTableName(), 381 'index' => $index->getIndexKey(), 382 'ascending' => $ascending, 383 ); 384 385 return $this; 386 } 387 388 389 /** 390 * Get the name of the query's primary object PHID column, for constructing 391 * JOIN clauses. Normally (and by default) this is just `"phid"`, but if the 392 * query construction requires a table alias it may be something like 393 * `"task.phid"`. 394 * 395 * @return string Column name. 396 * @task appsearch 397 */ 398 protected function getApplicationSearchObjectPHIDColumn() { 399 return 'phid'; 400 } 401 402 403 /** 404 * Determine if the JOINs built by ApplicationSearch might cause each primary 405 * object to return multiple result rows. Generally, this means the query 406 * needs an extra GROUP BY clause. 407 * 408 * @return bool True if the query may return multiple rows for each object. 409 * @task appsearch 410 */ 411 protected function getApplicationSearchMayJoinMultipleRows() { 412 foreach ($this->applicationSearchConstraints as $constraint) { 413 $type = $constraint['type']; 414 $value = $constraint['value']; 415 $cond = $constraint['cond']; 416 417 switch ($cond) { 418 case '=': 419 switch ($type) { 420 case 'string': 421 case 'int': 422 if (count((array)$value) > 1) { 423 return true; 424 } 425 break; 426 default: 427 throw new Exception(pht('Unknown index type "%s"!', $type)); 428 } 429 break; 430 case 'range': 431 // NOTE: It's possible to write a custom field where multiple rows 432 // match a range constraint, but we don't currently ship any in the 433 // upstream and I can't immediately come up with cases where this 434 // would make sense. 435 break; 436 default: 437 throw new Exception(pht('Unknown constraint condition "%s"!', $cond)); 438 } 439 } 440 441 return false; 442 } 443 444 445 /** 446 * Construct a GROUP BY clause appropriate for ApplicationSearch constraints. 447 * 448 * @param AphrontDatabaseConnection Connection executing the query. 449 * @return string Group clause. 450 * @task appsearch 451 */ 452 protected function buildApplicationSearchGroupClause( 453 AphrontDatabaseConnection $conn_r) { 454 455 if ($this->getApplicationSearchMayJoinMultipleRows()) { 456 return qsprintf( 457 $conn_r, 458 'GROUP BY %Q', 459 $this->getApplicationSearchObjectPHIDColumn()); 460 } else { 461 return ''; 462 } 463 } 464 465 466 /** 467 * Construct a JOIN clause appropriate for applying ApplicationSearch 468 * constraints. 469 * 470 * @param AphrontDatabaseConnection Connection executing the query. 471 * @return string Join clause. 472 * @task appsearch 473 */ 474 protected function buildApplicationSearchJoinClause( 475 AphrontDatabaseConnection $conn_r) { 476 477 $joins = array(); 478 foreach ($this->applicationSearchConstraints as $key => $constraint) { 479 $table = $constraint['table']; 480 $alias = 'appsearch_'.$key; 481 $index = $constraint['index']; 482 $cond = $constraint['cond']; 483 $phid_column = $this->getApplicationSearchObjectPHIDColumn(); 484 switch ($cond) { 485 case '=': 486 $type = $constraint['type']; 487 switch ($type) { 488 case 'string': 489 $constraint_clause = qsprintf( 490 $conn_r, 491 '%T.indexValue IN (%Ls)', 492 $alias, 493 (array)$constraint['value']); 494 break; 495 case 'int': 496 $constraint_clause = qsprintf( 497 $conn_r, 498 '%T.indexValue IN (%Ld)', 499 $alias, 500 (array)$constraint['value']); 501 break; 502 default: 503 throw new Exception(pht('Unknown index type "%s"!', $type)); 504 } 505 506 $joins[] = qsprintf( 507 $conn_r, 508 'JOIN %T %T ON %T.objectPHID = %Q 509 AND %T.indexKey = %s 510 AND (%Q)', 511 $table, 512 $alias, 513 $alias, 514 $phid_column, 515 $alias, 516 $index, 517 $constraint_clause); 518 break; 519 case 'range': 520 list($min, $max) = $constraint['value']; 521 if (($min === null) && ($max === null)) { 522 // If there's no actual range constraint, just move on. 523 break; 524 } 525 526 if ($min === null) { 527 $constraint_clause = qsprintf( 528 $conn_r, 529 '%T.indexValue <= %d', 530 $alias, 531 $max); 532 } else if ($max === null) { 533 $constraint_clause = qsprintf( 534 $conn_r, 535 '%T.indexValue >= %d', 536 $alias, 537 $min); 538 } else { 539 $constraint_clause = qsprintf( 540 $conn_r, 541 '%T.indexValue BETWEEN %d AND %d', 542 $alias, 543 $min, 544 $max); 545 } 546 547 $joins[] = qsprintf( 548 $conn_r, 549 'JOIN %T %T ON %T.objectPHID = %Q 550 AND %T.indexKey = %s 551 AND (%Q)', 552 $table, 553 $alias, 554 $alias, 555 $phid_column, 556 $alias, 557 $index, 558 $constraint_clause); 559 break; 560 default: 561 throw new Exception(pht('Unknown constraint condition "%s"!', $cond)); 562 } 563 } 564 565 foreach ($this->applicationSearchOrders as $key => $order) { 566 $table = $order['table']; 567 $alias = 'appsearch_order_'.$key; 568 $index = $order['index']; 569 $phid_column = $this->getApplicationSearchObjectPHIDColumn(); 570 571 $joins[] = qsprintf( 572 $conn_r, 573 'LEFT JOIN %T %T ON %T.objectPHID = %Q 574 AND %T.indexKey = %s', 575 $table, 576 $alias, 577 $alias, 578 $phid_column, 579 $alias, 580 $index); 581 } 582 583 return implode(' ', $joins); 584 } 585 586 protected function buildApplicationSearchOrders( 587 AphrontDatabaseConnection $conn_r, 588 $reverse) { 589 590 $orders = array(); 591 foreach ($this->applicationSearchOrders as $key => $order) { 592 $alias = 'appsearch_order_'.$key; 593 594 if ($order['ascending'] xor $reverse) { 595 $orders[] = qsprintf($conn_r, '%T.indexValue ASC', $alias); 596 } else { 597 $orders[] = qsprintf($conn_r, '%T.indexValue DESC', $alias); 598 } 599 } 600 601 return $orders; 602 } 603 604 protected function buildApplicationSearchPagination( 605 AphrontDatabaseConnection $conn_r, 606 $cursor) { 607 608 // We have to get the current field values on the cursor object. 609 $fields = PhabricatorCustomField::getObjectFields( 610 $cursor, 611 PhabricatorCustomField::ROLE_APPLICATIONSEARCH); 612 $fields->setViewer($this->getViewer()); 613 $fields->readFieldsFromStorage($cursor); 614 615 $fields = mpull($fields->getFields(), null, 'getFieldKey'); 616 617 $columns = array(); 618 foreach ($this->applicationSearchOrders as $key => $order) { 619 $alias = 'appsearch_order_'.$key; 620 621 $field = idx($fields, $order['key']); 622 623 $columns[] = array( 624 'name' => $alias.'.indexValue', 625 'value' => $field->getValueForStorage(), 626 'type' => $order['type'], 627 ); 628 } 629 630 return $columns; 631 } 632 633 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body
Generated: Sun Nov 30 09:20:46 2014 | Cross-referenced by PHPXref 0.7.1 |