[ Index ]

PHP Cross Reference of Phabricator

title

Body

[close]

/src/infrastructure/query/policy/ -> PhabricatorCursorPagedPolicyAwareQuery.php (source)

   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  }


Generated: Sun Nov 30 09:20:46 2014 Cross-referenced by PHPXref 0.7.1