Path: blob/master/src/infrastructure/query/policy/PhabricatorCursorPagedPolicyAwareQuery.php
12242 views
<?php12/**3* A query class which uses cursor-based paging. This paging is much more4* performant than offset-based paging in the presence of policy filtering.5*6* @task cursors Query Cursors7* @task clauses Building Query Clauses8* @task appsearch Integration with ApplicationSearch9* @task customfield Integration with CustomField10* @task paging Paging11* @task order Result Ordering12* @task edgelogic Working with Edge Logic13* @task spaces Working with Spaces14*/15abstract class PhabricatorCursorPagedPolicyAwareQuery16extends PhabricatorPolicyAwareQuery {1718private $externalCursorString;19private $internalCursorObject;20private $isQueryOrderReversed = false;21private $rawCursorRow;2223private $applicationSearchConstraints = array();24private $internalPaging;25private $orderVector;26private $groupVector;27private $builtinOrder;28private $edgeLogicConstraints = array();29private $edgeLogicConstraintsAreValid = false;30private $spacePHIDs;31private $spaceIsArchived;32private $ngrams = array();33private $ferretEngine;34private $ferretTokens = array();35private $ferretTables = array();36private $ferretQuery;37private $ferretMetadata = array();38private $ngramEngine;3940const FULLTEXT_RANK = '_ft_rank';41const FULLTEXT_MODIFIED = '_ft_epochModified';42const FULLTEXT_CREATED = '_ft_epochCreated';4344/* -( Cursors )------------------------------------------------------------ */4546protected function newExternalCursorStringForResult($object) {47if (!($object instanceof LiskDAO)) {48throw new Exception(49pht(50'Expected to be passed a result object of class "LiskDAO" in '.51'"newExternalCursorStringForResult()", actually passed "%s". '.52'Return storage objects from "loadPage()" or override '.53'"newExternalCursorStringForResult()".',54phutil_describe_type($object)));55}5657return (string)$object->getID();58}5960protected function newInternalCursorFromExternalCursor($cursor) {61$viewer = $this->getViewer();6263$query = newv(get_class($this), array());6465$query66->setParentQuery($this)67->setViewer($viewer);6869// We're copying our order vector to the subquery so that the subquery70// knows it should generate any supplemental information required by the71// ordering.7273// For example, Phriction documents may be ordered by title, but the title74// isn't a column in the "document" table: the query must JOIN the75// "content" table to perform the ordering. Passing the ordering to the76// subquery tells it that we need it to do that JOIN and attach relevant77// paging information to the internal cursor object.7879// We only expect to load a single result, so the actual result order does80// not matter. We only want the internal cursor for that result to look81// like a cursor this parent query would generate.82$query->setOrderVector($this->getOrderVector());8384$this->applyExternalCursorConstraintsToQuery($query, $cursor);8586// If we have a Ferret fulltext query, copy it to the subquery so that we87// generate ranking columns appropriately, and compute the correct object88// ranking score for the current query.89if ($this->ferretEngine) {90$query->withFerretConstraint($this->ferretEngine, $this->ferretTokens);91}9293// We're executing the subquery normally to make sure the viewer can94// actually see the object, and that it's a completely valid object which95// passes all filtering and policy checks. You aren't allowed to use an96// object you can't see as a cursor, since this can leak information.97$result = $query->executeOne();98if (!$result) {99$this->throwCursorException(100pht(101'Cursor "%s" does not identify a valid object in query "%s".',102$cursor,103get_class($this)));104}105106// Now that we made sure the viewer can actually see the object the107// external cursor identifies, return the internal cursor the query108// generated as a side effect while loading the object.109return $query->getInternalCursorObject();110}111112final protected function throwCursorException($message) {113throw new PhabricatorInvalidQueryCursorException($message);114}115116protected function applyExternalCursorConstraintsToQuery(117PhabricatorCursorPagedPolicyAwareQuery $subquery,118$cursor) {119$subquery->withIDs(array($cursor));120}121122protected function newPagingMapFromCursorObject(123PhabricatorQueryCursor $cursor,124array $keys) {125126$object = $cursor->getObject();127128return $this->newPagingMapFromPartialObject($object);129}130131protected function newPagingMapFromPartialObject($object) {132return array(133'id' => (int)$object->getID(),134);135}136137private function getExternalCursorStringForResult($object) {138$cursor = $this->newExternalCursorStringForResult($object);139140if (!is_string($cursor)) {141throw new Exception(142pht(143'Expected "newExternalCursorStringForResult()" in class "%s" to '.144'return a string, but got "%s".',145get_class($this),146phutil_describe_type($cursor)));147}148149return $cursor;150}151152final protected function getExternalCursorString() {153return $this->externalCursorString;154}155156private function setExternalCursorString($external_cursor) {157$this->externalCursorString = $external_cursor;158return $this;159}160161final protected function getIsQueryOrderReversed() {162return $this->isQueryOrderReversed;163}164165final protected function setIsQueryOrderReversed($is_reversed) {166$this->isQueryOrderReversed = $is_reversed;167return $this;168}169170private function getInternalCursorObject() {171return $this->internalCursorObject;172}173174private function setInternalCursorObject(175PhabricatorQueryCursor $cursor) {176$this->internalCursorObject = $cursor;177return $this;178}179180private function getInternalCursorFromExternalCursor(181$cursor_string) {182183$cursor_object = $this->newInternalCursorFromExternalCursor($cursor_string);184185if (!($cursor_object instanceof PhabricatorQueryCursor)) {186throw new Exception(187pht(188'Expected "newInternalCursorFromExternalCursor()" to return an '.189'object of class "PhabricatorQueryCursor", but got "%s" (in '.190'class "%s").',191phutil_describe_type($cursor_object),192get_class($this)));193}194195return $cursor_object;196}197198private function getPagingMapFromCursorObject(199PhabricatorQueryCursor $cursor,200array $keys) {201202$map = $this->newPagingMapFromCursorObject($cursor, $keys);203204if (!is_array($map)) {205throw new Exception(206pht(207'Expected "newPagingMapFromCursorObject()" to return a map of '.208'paging values, but got "%s" (in class "%s").',209phutil_describe_type($map),210get_class($this)));211}212213if ($this->supportsFerretEngine()) {214if ($this->hasFerretOrder()) {215$map += array(216'rank' =>217$cursor->getRawRowProperty(self::FULLTEXT_RANK),218'fulltext-modified' =>219$cursor->getRawRowProperty(self::FULLTEXT_MODIFIED),220'fulltext-created' =>221$cursor->getRawRowProperty(self::FULLTEXT_CREATED),222);223}224}225226foreach ($keys as $key) {227if (!array_key_exists($key, $map)) {228throw new Exception(229pht(230'Map returned by "newPagingMapFromCursorObject()" in class "%s" '.231'omits required key "%s".',232get_class($this),233$key));234}235}236237return $map;238}239240final protected function nextPage(array $page) {241if (!$page) {242return;243}244245$cursor = id(new PhabricatorQueryCursor())246->setObject(last($page));247248if ($this->rawCursorRow) {249$cursor->setRawRow($this->rawCursorRow);250}251252$this->setInternalCursorObject($cursor);253}254255final public function getFerretMetadata() {256if (!$this->supportsFerretEngine()) {257throw new Exception(258pht(259'Unable to retrieve Ferret engine metadata, this class ("%s") does '.260'not support the Ferret engine.',261get_class($this)));262}263264return $this->ferretMetadata;265}266267protected function loadPage() {268$object = $this->newResultObject();269270if (!$object instanceof PhabricatorLiskDAO) {271throw new Exception(272pht(273'Query class ("%s") did not return the correct type of object '.274'from "newResultObject()" (expected a subclass of '.275'"PhabricatorLiskDAO", found "%s"). Return an object of the '.276'expected type (this is common), or implement a custom '.277'"loadPage()" method (this is unusual in modern code).',278get_class($this),279phutil_describe_type($object)));280}281282return $this->loadStandardPage($object);283}284285protected function loadStandardPage(PhabricatorLiskDAO $table) {286$rows = $this->loadStandardPageRows($table);287return $table->loadAllFromArray($rows);288}289290protected function loadStandardPageRows(PhabricatorLiskDAO $table) {291$conn = $table->establishConnection('r');292return $this->loadStandardPageRowsWithConnection(293$conn,294$table->getTableName());295}296297protected function loadStandardPageRowsWithConnection(298AphrontDatabaseConnection $conn,299$table_name) {300301$query = $this->buildStandardPageQuery($conn, $table_name);302303$rows = queryfx_all($conn, '%Q', $query);304$rows = $this->didLoadRawRows($rows);305306return $rows;307}308309protected function buildStandardPageQuery(310AphrontDatabaseConnection $conn,311$table_name) {312313$table_alias = $this->getPrimaryTableAlias();314if ($table_alias === null) {315$table_alias = qsprintf($conn, '');316} else {317$table_alias = qsprintf($conn, '%T', $table_alias);318}319320return qsprintf(321$conn,322'%Q FROM %T %Q %Q %Q %Q %Q %Q %Q',323$this->buildSelectClause($conn),324$table_name,325$table_alias,326$this->buildJoinClause($conn),327$this->buildWhereClause($conn),328$this->buildGroupClause($conn),329$this->buildHavingClause($conn),330$this->buildOrderClause($conn),331$this->buildLimitClause($conn));332}333334protected function didLoadRawRows(array $rows) {335$this->rawCursorRow = last($rows);336337if ($this->ferretEngine) {338foreach ($rows as $row) {339$phid = $row['phid'];340341$metadata = id(new PhabricatorFerretMetadata())342->setPHID($phid)343->setEngine($this->ferretEngine)344->setRelevance(idx($row, self::FULLTEXT_RANK));345346$this->ferretMetadata[$phid] = $metadata;347348unset($row[self::FULLTEXT_RANK]);349unset($row[self::FULLTEXT_MODIFIED]);350unset($row[self::FULLTEXT_CREATED]);351}352}353354return $rows;355}356357final protected function buildLimitClause(AphrontDatabaseConnection $conn) {358if ($this->shouldLimitResults()) {359$limit = $this->getRawResultLimit();360if ($limit) {361return qsprintf($conn, 'LIMIT %d', $limit);362}363}364365return qsprintf($conn, '');366}367368protected function shouldLimitResults() {369return true;370}371372final protected function didLoadResults(array $results) {373if ($this->getIsQueryOrderReversed()) {374$results = array_reverse($results, $preserve_keys = true);375}376377return $results;378}379380final public function newIterator() {381return new PhabricatorQueryIterator($this);382}383384final public function executeWithCursorPager(AphrontCursorPagerView $pager) {385$limit = $pager->getPageSize();386387$this->setLimit($limit + 1);388389$after_id = phutil_string_cast($pager->getAfterID());390$before_id = phutil_string_cast($pager->getBeforeID());391392if (phutil_nonempty_string($after_id)) {393$this->setExternalCursorString($after_id);394} else if (phutil_nonempty_string($before_id)) {395$this->setExternalCursorString($before_id);396$this->setIsQueryOrderReversed(true);397}398399$results = $this->execute();400$count = count($results);401402$sliced_results = $pager->sliceResults($results);403if ($sliced_results) {404405// If we have results, generate external-facing cursors from the visible406// results. This stops us from leaking any internal details about objects407// which we loaded but which were not visible to the viewer.408409if ($pager->getBeforeID() || ($count > $limit)) {410$last_object = last($sliced_results);411$cursor = $this->getExternalCursorStringForResult($last_object);412$pager->setNextPageID($cursor);413}414415if ($pager->getAfterID() ||416($pager->getBeforeID() && ($count > $limit))) {417$head_object = head($sliced_results);418$cursor = $this->getExternalCursorStringForResult($head_object);419$pager->setPrevPageID($cursor);420}421}422423return $sliced_results;424}425426427/**428* Return the alias this query uses to identify the primary table.429*430* Some automatic query constructions may need to be qualified with a table431* alias if the query performs joins which make column names ambiguous. If432* this is the case, return the alias for the primary table the query433* uses; generally the object table which has `id` and `phid` columns.434*435* @return string Alias for the primary table.436*/437protected function getPrimaryTableAlias() {438return null;439}440441public function newResultObject() {442return null;443}444445446/* -( Building Query Clauses )--------------------------------------------- */447448449/**450* @task clauses451*/452protected function buildSelectClause(AphrontDatabaseConnection $conn) {453$parts = $this->buildSelectClauseParts($conn);454return $this->formatSelectClause($conn, $parts);455}456457458/**459* @task clauses460*/461protected function buildSelectClauseParts(AphrontDatabaseConnection $conn) {462$select = array();463464$alias = $this->getPrimaryTableAlias();465if ($alias) {466$select[] = qsprintf($conn, '%T.*', $alias);467} else {468$select[] = qsprintf($conn, '*');469}470471$select[] = $this->buildEdgeLogicSelectClause($conn);472$select[] = $this->buildFerretSelectClause($conn);473474return $select;475}476477478/**479* @task clauses480*/481protected function buildJoinClause(AphrontDatabaseConnection $conn) {482$joins = $this->buildJoinClauseParts($conn);483return $this->formatJoinClause($conn, $joins);484}485486487/**488* @task clauses489*/490protected function buildJoinClauseParts(AphrontDatabaseConnection $conn) {491$joins = array();492$joins[] = $this->buildEdgeLogicJoinClause($conn);493$joins[] = $this->buildApplicationSearchJoinClause($conn);494$joins[] = $this->buildNgramsJoinClause($conn);495$joins[] = $this->buildFerretJoinClause($conn);496return $joins;497}498499500/**501* @task clauses502*/503protected function buildWhereClause(AphrontDatabaseConnection $conn) {504$where = $this->buildWhereClauseParts($conn);505return $this->formatWhereClause($conn, $where);506}507508509/**510* @task clauses511*/512protected function buildWhereClauseParts(AphrontDatabaseConnection $conn) {513$where = array();514$where[] = $this->buildPagingWhereClause($conn);515$where[] = $this->buildEdgeLogicWhereClause($conn);516$where[] = $this->buildSpacesWhereClause($conn);517$where[] = $this->buildNgramsWhereClause($conn);518$where[] = $this->buildFerretWhereClause($conn);519$where[] = $this->buildApplicationSearchWhereClause($conn);520return $where;521}522523524/**525* @task clauses526*/527protected function buildHavingClause(AphrontDatabaseConnection $conn) {528$having = $this->buildHavingClauseParts($conn);529$having[] = $this->buildPagingHavingClause($conn);530return $this->formatHavingClause($conn, $having);531}532533534/**535* @task clauses536*/537protected function buildHavingClauseParts(AphrontDatabaseConnection $conn) {538$having = array();539$having[] = $this->buildEdgeLogicHavingClause($conn);540return $having;541}542543544/**545* @task clauses546*/547protected function buildGroupClause(AphrontDatabaseConnection $conn) {548if (!$this->shouldGroupQueryResultRows()) {549return qsprintf($conn, '');550}551552return qsprintf(553$conn,554'GROUP BY %Q',555$this->getApplicationSearchObjectPHIDColumn($conn));556}557558559/**560* @task clauses561*/562protected function shouldGroupQueryResultRows() {563if ($this->shouldGroupEdgeLogicResultRows()) {564return true;565}566567if ($this->getApplicationSearchMayJoinMultipleRows()) {568return true;569}570571if ($this->shouldGroupNgramResultRows()) {572return true;573}574575if ($this->shouldGroupFerretResultRows()) {576return true;577}578579return false;580}581582583584/* -( Paging )------------------------------------------------------------- */585586587private function buildPagingWhereClause(AphrontDatabaseConnection $conn) {588if ($this->shouldPageWithHavingClause()) {589return null;590}591592return $this->buildPagingClause($conn);593}594595private function buildPagingHavingClause(AphrontDatabaseConnection $conn) {596if (!$this->shouldPageWithHavingClause()) {597return null;598}599600return $this->buildPagingClause($conn);601}602603private function shouldPageWithHavingClause() {604// If any of the paging conditions reference dynamic columns, we need to605// put the paging conditions in a "HAVING" clause instead of a "WHERE"606// clause.607608// For example, this happens when paging on the Ferret "rank" column,609// since the "rank" value is computed dynamically in the SELECT statement.610611$orderable = $this->getOrderableColumns();612$vector = $this->getOrderVector();613614foreach ($vector as $order) {615$key = $order->getOrderKey();616$column = $orderable[$key];617618if (!empty($column['having'])) {619return true;620}621}622623return false;624}625626/**627* @task paging628*/629protected function buildPagingClause(AphrontDatabaseConnection $conn) {630$orderable = $this->getOrderableColumns();631$vector = $this->getQueryableOrderVector();632633// If we don't have a cursor object yet, it means we're trying to load634// the first result page. We may need to build a cursor object from the635// external string, or we may not need a paging clause yet.636$cursor_object = $this->getInternalCursorObject();637if (!$cursor_object) {638$external_cursor = $this->getExternalCursorString();639if ($external_cursor !== null) {640$cursor_object = $this->getInternalCursorFromExternalCursor(641$external_cursor);642}643}644645// If we still don't have a cursor object, this is the first result page646// and we aren't paging it. We don't need to build a paging clause.647if (!$cursor_object) {648return qsprintf($conn, '');649}650651$reversed = $this->getIsQueryOrderReversed();652653$keys = array();654foreach ($vector as $order) {655$keys[] = $order->getOrderKey();656}657$keys = array_fuse($keys);658659$value_map = $this->getPagingMapFromCursorObject(660$cursor_object,661$keys);662663$columns = array();664foreach ($vector as $order) {665$key = $order->getOrderKey();666667$column = $orderable[$key];668$column['value'] = $value_map[$key];669670// If the vector component is reversed, we need to reverse whatever the671// order of the column is.672if ($order->getIsReversed()) {673$column['reverse'] = !idx($column, 'reverse', false);674}675676$columns[] = $column;677}678679return $this->buildPagingClauseFromMultipleColumns(680$conn,681$columns,682array(683'reversed' => $reversed,684));685}686687688/**689* Simplifies the task of constructing a paging clause across multiple690* columns. In the general case, this looks like:691*692* A > a OR (A = a AND B > b) OR (A = a AND B = b AND C > c)693*694* To build a clause, specify the name, type, and value of each column695* to include:696*697* $this->buildPagingClauseFromMultipleColumns(698* $conn_r,699* array(700* array(701* 'table' => 't',702* 'column' => 'title',703* 'type' => 'string',704* 'value' => $cursor->getTitle(),705* 'reverse' => true,706* ),707* array(708* 'table' => 't',709* 'column' => 'id',710* 'type' => 'int',711* 'value' => $cursor->getID(),712* ),713* ),714* array(715* 'reversed' => $is_reversed,716* ));717*718* This method will then return a composable clause for inclusion in WHERE.719*720* @param AphrontDatabaseConnection Connection query will execute on.721* @param list<map> Column description dictionaries.722* @param map Additional construction options.723* @return string Query clause.724* @task paging725*/726final protected function buildPagingClauseFromMultipleColumns(727AphrontDatabaseConnection $conn,728array $columns,729array $options) {730731foreach ($columns as $column) {732PhutilTypeSpec::checkMap(733$column,734array(735'table' => 'optional string|null',736'column' => 'string',737'value' => 'wild',738'type' => 'string',739'reverse' => 'optional bool',740'unique' => 'optional bool',741'null' => 'optional string|null',742'requires-ferret' => 'optional bool',743'having' => 'optional bool',744));745}746747PhutilTypeSpec::checkMap(748$options,749array(750'reversed' => 'optional bool',751));752753$is_query_reversed = idx($options, 'reversed', false);754755$clauses = array();756$accumulated = array();757$last_key = last_key($columns);758foreach ($columns as $key => $column) {759$type = $column['type'];760761$null = idx($column, 'null');762if ($column['value'] === null) {763if ($null) {764$value = null;765} else {766throw new Exception(767pht(768'Column "%s" has null value, but does not specify a null '.769'behavior.',770$key));771}772} else {773switch ($type) {774case 'int':775$value = qsprintf($conn, '%d', $column['value']);776break;777case 'float':778$value = qsprintf($conn, '%f', $column['value']);779break;780case 'string':781$value = qsprintf($conn, '%s', $column['value']);782break;783default:784throw new Exception(785pht(786'Column "%s" has unknown column type "%s".',787$column['column'],788$type));789}790}791792$is_column_reversed = idx($column, 'reverse', false);793$reverse = ($is_query_reversed xor $is_column_reversed);794795$clause = $accumulated;796797$table_name = idx($column, 'table');798$column_name = $column['column'];799if ($table_name !== null) {800$field = qsprintf($conn, '%T.%T', $table_name, $column_name);801} else {802$field = qsprintf($conn, '%T', $column_name);803}804805$parts = array();806if ($null) {807$can_page_if_null = ($null === 'head');808$can_page_if_nonnull = ($null === 'tail');809810if ($reverse) {811$can_page_if_null = !$can_page_if_null;812$can_page_if_nonnull = !$can_page_if_nonnull;813}814815$subclause = null;816if ($can_page_if_null && $value === null) {817$parts[] = qsprintf(818$conn,819'(%Q IS NOT NULL)',820$field);821} else if ($can_page_if_nonnull && $value !== null) {822$parts[] = qsprintf(823$conn,824'(%Q IS NULL)',825$field);826}827}828829if ($value !== null) {830$parts[] = qsprintf(831$conn,832'%Q %Q %Q',833$field,834$reverse ? qsprintf($conn, '>') : qsprintf($conn, '<'),835$value);836}837838if ($parts) {839$clause[] = qsprintf($conn, '%LO', $parts);840}841842if ($clause) {843$clauses[] = qsprintf($conn, '%LA', $clause);844}845846if ($value === null) {847$accumulated[] = qsprintf(848$conn,849'%Q IS NULL',850$field);851} else {852$accumulated[] = qsprintf(853$conn,854'%Q = %Q',855$field,856$value);857}858}859860if ($clauses) {861return qsprintf($conn, '%LO', $clauses);862}863864return qsprintf($conn, '');865}866867868/* -( Result Ordering )---------------------------------------------------- */869870871/**872* Select a result ordering.873*874* This is a high-level method which selects an ordering from a predefined875* list of builtin orders, as provided by @{method:getBuiltinOrders}. These876* options are user-facing and not exhaustive, but are generally convenient877* and meaningful.878*879* You can also use @{method:setOrderVector} to specify a low-level ordering880* across individual orderable columns. This offers greater control but is881* also more involved.882*883* @param string Key of a builtin order supported by this query.884* @return this885* @task order886*/887public function setOrder($order) {888$aliases = $this->getBuiltinOrderAliasMap();889890if (empty($aliases[$order])) {891throw new Exception(892pht(893'Query "%s" does not support a builtin order "%s". Supported orders '.894'are: %s.',895get_class($this),896$order,897implode(', ', array_keys($aliases))));898}899900$this->builtinOrder = $aliases[$order];901$this->orderVector = null;902903return $this;904}905906907/**908* Set a grouping order to apply before primary result ordering.909*910* This allows you to preface the query order vector with additional orders,911* so you can effect "group by" queries while still respecting "order by".912*913* This is a high-level method which works alongside @{method:setOrder}. For914* lower-level control over order vectors, use @{method:setOrderVector}.915*916* @param PhabricatorQueryOrderVector|list<string> List of order keys.917* @return this918* @task order919*/920public function setGroupVector($vector) {921$this->groupVector = $vector;922$this->orderVector = null;923924return $this;925}926927928/**929* Get builtin orders for this class.930*931* In application UIs, we want to be able to present users with a small932* selection of meaningful order options (like "Order by Title") rather than933* an exhaustive set of column ordering options.934*935* Meaningful user-facing orders are often really orders across multiple936* columns: for example, a "title" ordering is usually implemented as a937* "title, id" ordering under the hood.938*939* Builtin orders provide a mapping from convenient, understandable940* user-facing orders to implementations.941*942* A builtin order should provide these keys:943*944* - `vector` (`list<string>`): The actual order vector to use.945* - `name` (`string`): Human-readable order name.946*947* @return map<string, wild> Map from builtin order keys to specification.948* @task order949*/950public function getBuiltinOrders() {951$orders = array(952'newest' => array(953'vector' => array('id'),954'name' => pht('Creation (Newest First)'),955'aliases' => array('created'),956),957'oldest' => array(958'vector' => array('-id'),959'name' => pht('Creation (Oldest First)'),960),961);962963$object = $this->newResultObject();964if ($object instanceof PhabricatorCustomFieldInterface) {965$list = PhabricatorCustomField::getObjectFields(966$object,967PhabricatorCustomField::ROLE_APPLICATIONSEARCH);968foreach ($list->getFields() as $field) {969$index = $field->buildOrderIndex();970if (!$index) {971continue;972}973974$legacy_key = 'custom:'.$field->getFieldKey();975$modern_key = $field->getModernFieldKey();976977$orders[$modern_key] = array(978'vector' => array($modern_key, 'id'),979'name' => $field->getFieldName(),980'aliases' => array($legacy_key),981);982983$orders['-'.$modern_key] = array(984'vector' => array('-'.$modern_key, '-id'),985'name' => pht('%s (Reversed)', $field->getFieldName()),986);987}988}989990if ($this->supportsFerretEngine()) {991$orders['relevance'] = array(992'vector' => array('rank', 'fulltext-modified', 'id'),993'name' => pht('Relevance'),994);995}996997return $orders;998}9991000public function getBuiltinOrderAliasMap() {1001$orders = $this->getBuiltinOrders();10021003$map = array();1004foreach ($orders as $key => $order) {1005$keys = array();1006$keys[] = $key;1007foreach (idx($order, 'aliases', array()) as $alias) {1008$keys[] = $alias;1009}10101011foreach ($keys as $alias) {1012if (isset($map[$alias])) {1013throw new Exception(1014pht(1015'Two builtin orders ("%s" and "%s") define the same key or '.1016'alias ("%s"). Each order alias and key must be unique and '.1017'identify a single order.',1018$key,1019$map[$alias],1020$alias));1021}1022$map[$alias] = $key;1023}1024}10251026return $map;1027}102810291030/**1031* Set a low-level column ordering.1032*1033* This is a low-level method which offers granular control over column1034* ordering. In most cases, applications can more easily use1035* @{method:setOrder} to choose a high-level builtin order.1036*1037* To set an order vector, specify a list of order keys as provided by1038* @{method:getOrderableColumns}.1039*1040* @param PhabricatorQueryOrderVector|list<string> List of order keys.1041* @return this1042* @task order1043*/1044public function setOrderVector($vector) {1045$vector = PhabricatorQueryOrderVector::newFromVector($vector);10461047$orderable = $this->getOrderableColumns();10481049// Make sure that all the components identify valid columns.1050$unique = array();1051foreach ($vector as $order) {1052$key = $order->getOrderKey();1053if (empty($orderable[$key])) {1054$valid = implode(', ', array_keys($orderable));1055throw new Exception(1056pht(1057'This query ("%s") does not support sorting by order key "%s". '.1058'Supported orders are: %s.',1059get_class($this),1060$key,1061$valid));1062}10631064$unique[$key] = idx($orderable[$key], 'unique', false);1065}10661067// Make sure that the last column is unique so that this is a strong1068// ordering which can be used for paging.1069$last = last($unique);1070if ($last !== true) {1071throw new Exception(1072pht(1073'Order vector "%s" is invalid: the last column in an order must '.1074'be a column with unique values, but "%s" is not unique.',1075$vector->getAsString(),1076last_key($unique)));1077}10781079// Make sure that other columns are not unique; an ordering like "id, name"1080// does not make sense because only "id" can ever have an effect.1081array_pop($unique);1082foreach ($unique as $key => $is_unique) {1083if ($is_unique) {1084throw new Exception(1085pht(1086'Order vector "%s" is invalid: only the last column in an order '.1087'may be unique, but "%s" is a unique column and not the last '.1088'column in the order.',1089$vector->getAsString(),1090$key));1091}1092}10931094$this->orderVector = $vector;1095return $this;1096}109710981099/**1100* Get the effective order vector.1101*1102* @return PhabricatorQueryOrderVector Effective vector.1103* @task order1104*/1105protected function getOrderVector() {1106if (!$this->orderVector) {1107if ($this->builtinOrder !== null) {1108$builtin_order = idx($this->getBuiltinOrders(), $this->builtinOrder);1109$vector = $builtin_order['vector'];1110} else {1111$vector = $this->getDefaultOrderVector();1112}11131114if ($this->groupVector) {1115$group = PhabricatorQueryOrderVector::newFromVector($this->groupVector);1116$group->appendVector($vector);1117$vector = $group;1118}11191120$vector = PhabricatorQueryOrderVector::newFromVector($vector);11211122// We call setOrderVector() here to apply checks to the default vector.1123// This catches any errors in the implementation.1124$this->setOrderVector($vector);1125}11261127return $this->orderVector;1128}112911301131/**1132* @task order1133*/1134protected function getDefaultOrderVector() {1135return array('id');1136}113711381139/**1140* @task order1141*/1142public function getOrderableColumns() {1143$cache = PhabricatorCaches::getRequestCache();1144$class = get_class($this);1145$cache_key = 'query.orderablecolumns.'.$class;11461147$columns = $cache->getKey($cache_key);1148if ($columns !== null) {1149return $columns;1150}11511152$columns = array(1153'id' => array(1154'table' => $this->getPrimaryTableAlias(),1155'column' => 'id',1156'reverse' => false,1157'type' => 'int',1158'unique' => true,1159),1160);11611162$object = $this->newResultObject();1163if ($object instanceof PhabricatorCustomFieldInterface) {1164$list = PhabricatorCustomField::getObjectFields(1165$object,1166PhabricatorCustomField::ROLE_APPLICATIONSEARCH);1167foreach ($list->getFields() as $field) {1168$index = $field->buildOrderIndex();1169if (!$index) {1170continue;1171}11721173$digest = $field->getFieldIndex();11741175$key = $field->getModernFieldKey();11761177$columns[$key] = array(1178'table' => 'appsearch_order_'.$digest,1179'column' => 'indexValue',1180'type' => $index->getIndexValueType(),1181'null' => 'tail',1182'customfield' => true,1183'customfield.index.table' => $index->getTableName(),1184'customfield.index.key' => $digest,1185);1186}1187}11881189if ($this->supportsFerretEngine()) {1190$columns['rank'] = array(1191'table' => null,1192'column' => self::FULLTEXT_RANK,1193'type' => 'int',1194'requires-ferret' => true,1195'having' => true,1196);1197$columns['fulltext-created'] = array(1198'table' => null,1199'column' => self::FULLTEXT_CREATED,1200'type' => 'int',1201'requires-ferret' => true,1202);1203$columns['fulltext-modified'] = array(1204'table' => null,1205'column' => self::FULLTEXT_MODIFIED,1206'type' => 'int',1207'requires-ferret' => true,1208);1209}12101211$cache->setKey($cache_key, $columns);12121213return $columns;1214}121512161217/**1218* @task order1219*/1220final protected function buildOrderClause(1221AphrontDatabaseConnection $conn,1222$for_union = false) {12231224$orderable = $this->getOrderableColumns();1225$vector = $this->getQueryableOrderVector();12261227$parts = array();1228foreach ($vector as $order) {1229$part = $orderable[$order->getOrderKey()];12301231if ($order->getIsReversed()) {1232$part['reverse'] = !idx($part, 'reverse', false);1233}1234$parts[] = $part;1235}12361237return $this->formatOrderClause($conn, $parts, $for_union);1238}12391240/**1241* @task order1242*/1243private function getQueryableOrderVector() {1244$vector = $this->getOrderVector();1245$orderable = $this->getOrderableColumns();12461247$keep = array();1248foreach ($vector as $order) {1249$column = $orderable[$order->getOrderKey()];12501251// If this is a Ferret fulltext column but the query doesn't actually1252// have a fulltext query, we'll skip most of the Ferret stuff and won't1253// actually have the columns in the result set. Just skip them.1254if (!empty($column['requires-ferret'])) {1255if (!$this->getFerretTokens()) {1256continue;1257}1258}12591260$keep[] = $order->getAsScalar();1261}12621263return PhabricatorQueryOrderVector::newFromVector($keep);1264}12651266/**1267* @task order1268*/1269protected function formatOrderClause(1270AphrontDatabaseConnection $conn,1271array $parts,1272$for_union = false) {12731274$is_query_reversed = $this->getIsQueryOrderReversed();12751276$sql = array();1277foreach ($parts as $key => $part) {1278$is_column_reversed = !empty($part['reverse']);12791280$descending = true;1281if ($is_query_reversed) {1282$descending = !$descending;1283}12841285if ($is_column_reversed) {1286$descending = !$descending;1287}12881289$table = idx($part, 'table');12901291// When we're building an ORDER BY clause for a sequence of UNION1292// statements, we can't refer to tables from the subqueries.1293if ($for_union) {1294$table = null;1295}12961297$column = $part['column'];12981299if ($table !== null) {1300$field = qsprintf($conn, '%T.%T', $table, $column);1301} else {1302$field = qsprintf($conn, '%T', $column);1303}13041305$null = idx($part, 'null');1306if ($null) {1307switch ($null) {1308case 'head':1309$null_field = qsprintf($conn, '(%Q IS NULL)', $field);1310break;1311case 'tail':1312$null_field = qsprintf($conn, '(%Q IS NOT NULL)', $field);1313break;1314default:1315throw new Exception(1316pht(1317'NULL value "%s" is invalid. Valid values are "head" and '.1318'"tail".',1319$null));1320}13211322if ($descending) {1323$sql[] = qsprintf($conn, '%Q DESC', $null_field);1324} else {1325$sql[] = qsprintf($conn, '%Q ASC', $null_field);1326}1327}13281329if ($descending) {1330$sql[] = qsprintf($conn, '%Q DESC', $field);1331} else {1332$sql[] = qsprintf($conn, '%Q ASC', $field);1333}1334}13351336return qsprintf($conn, 'ORDER BY %LQ', $sql);1337}133813391340/* -( Application Search )------------------------------------------------- */134113421343/**1344* Constrain the query with an ApplicationSearch index, requiring field values1345* contain at least one of the values in a set.1346*1347* This constraint can build the most common types of queries, like:1348*1349* - Find users with shirt sizes "X" or "XL".1350* - Find shoes with size "13".1351*1352* @param PhabricatorCustomFieldIndexStorage Table where the index is stored.1353* @param string|list<string> One or more values to filter by.1354* @return this1355* @task appsearch1356*/1357public function withApplicationSearchContainsConstraint(1358PhabricatorCustomFieldIndexStorage $index,1359$value) {13601361$values = (array)$value;13621363$data_values = array();1364$constraint_values = array();1365foreach ($values as $value) {1366if ($value instanceof PhabricatorQueryConstraint) {1367$constraint_values[] = $value;1368} else {1369$data_values[] = $value;1370}1371}13721373$alias = 'appsearch_'.count($this->applicationSearchConstraints);13741375$this->applicationSearchConstraints[] = array(1376'type' => $index->getIndexValueType(),1377'cond' => '=',1378'table' => $index->getTableName(),1379'index' => $index->getIndexKey(),1380'alias' => $alias,1381'value' => $values,1382'data' => $data_values,1383'constraints' => $constraint_values,1384);13851386return $this;1387}138813891390/**1391* Constrain the query with an ApplicationSearch index, requiring values1392* exist in a given range.1393*1394* This constraint is useful for expressing date ranges:1395*1396* - Find events between July 1st and July 7th.1397*1398* The ends of the range are inclusive, so a `$min` of `3` and a `$max` of1399* `5` will match fields with values `3`, `4`, or `5`. Providing `null` for1400* either end of the range will leave that end of the constraint open.1401*1402* @param PhabricatorCustomFieldIndexStorage Table where the index is stored.1403* @param int|null Minimum permissible value, inclusive.1404* @param int|null Maximum permissible value, inclusive.1405* @return this1406* @task appsearch1407*/1408public function withApplicationSearchRangeConstraint(1409PhabricatorCustomFieldIndexStorage $index,1410$min,1411$max) {14121413$index_type = $index->getIndexValueType();1414if ($index_type != 'int') {1415throw new Exception(1416pht(1417'Attempting to apply a range constraint to a field with index type '.1418'"%s", expected type "%s".',1419$index_type,1420'int'));1421}14221423$alias = 'appsearch_'.count($this->applicationSearchConstraints);14241425$this->applicationSearchConstraints[] = array(1426'type' => $index->getIndexValueType(),1427'cond' => 'range',1428'table' => $index->getTableName(),1429'index' => $index->getIndexKey(),1430'alias' => $alias,1431'value' => array($min, $max),1432'data' => null,1433'constraints' => null,1434);14351436return $this;1437}143814391440/**1441* Get the name of the query's primary object PHID column, for constructing1442* JOIN clauses. Normally (and by default) this is just `"phid"`, but it may1443* be something more exotic.1444*1445* See @{method:getPrimaryTableAlias} if the column needs to be qualified with1446* a table alias.1447*1448* @param AphrontDatabaseConnection Connection executing queries.1449* @return PhutilQueryString Column name.1450* @task appsearch1451*/1452protected function getApplicationSearchObjectPHIDColumn(1453AphrontDatabaseConnection $conn) {14541455if ($this->getPrimaryTableAlias()) {1456return qsprintf($conn, '%T.phid', $this->getPrimaryTableAlias());1457} else {1458return qsprintf($conn, 'phid');1459}1460}146114621463/**1464* Determine if the JOINs built by ApplicationSearch might cause each primary1465* object to return multiple result rows. Generally, this means the query1466* needs an extra GROUP BY clause.1467*1468* @return bool True if the query may return multiple rows for each object.1469* @task appsearch1470*/1471protected function getApplicationSearchMayJoinMultipleRows() {1472foreach ($this->applicationSearchConstraints as $constraint) {1473$type = $constraint['type'];1474$value = $constraint['value'];1475$cond = $constraint['cond'];14761477switch ($cond) {1478case '=':1479switch ($type) {1480case 'string':1481case 'int':1482if (count($value) > 1) {1483return true;1484}1485break;1486default:1487throw new Exception(pht('Unknown index type "%s"!', $type));1488}1489break;1490case 'range':1491// NOTE: It's possible to write a custom field where multiple rows1492// match a range constraint, but we don't currently ship any in the1493// upstream and I can't immediately come up with cases where this1494// would make sense.1495break;1496default:1497throw new Exception(pht('Unknown constraint condition "%s"!', $cond));1498}1499}15001501return false;1502}150315041505/**1506* Construct a GROUP BY clause appropriate for ApplicationSearch constraints.1507*1508* @param AphrontDatabaseConnection Connection executing the query.1509* @return string Group clause.1510* @task appsearch1511*/1512protected function buildApplicationSearchGroupClause(1513AphrontDatabaseConnection $conn) {15141515if ($this->getApplicationSearchMayJoinMultipleRows()) {1516return qsprintf(1517$conn,1518'GROUP BY %Q',1519$this->getApplicationSearchObjectPHIDColumn($conn));1520} else {1521return qsprintf($conn, '');1522}1523}152415251526/**1527* Construct a JOIN clause appropriate for applying ApplicationSearch1528* constraints.1529*1530* @param AphrontDatabaseConnection Connection executing the query.1531* @return string Join clause.1532* @task appsearch1533*/1534protected function buildApplicationSearchJoinClause(1535AphrontDatabaseConnection $conn) {15361537$joins = array();1538foreach ($this->applicationSearchConstraints as $key => $constraint) {1539$table = $constraint['table'];1540$alias = $constraint['alias'];1541$index = $constraint['index'];1542$cond = $constraint['cond'];1543$phid_column = $this->getApplicationSearchObjectPHIDColumn($conn);1544switch ($cond) {1545case '=':1546// Figure out whether we need to do a LEFT JOIN or not. We need to1547// LEFT JOIN if we're going to select "IS NULL" rows.1548$join_type = qsprintf($conn, 'JOIN');1549foreach ($constraint['constraints'] as $query_constraint) {1550$op = $query_constraint->getOperator();1551if ($op === PhabricatorQueryConstraint::OPERATOR_NULL) {1552$join_type = qsprintf($conn, 'LEFT JOIN');1553break;1554}1555}15561557$joins[] = qsprintf(1558$conn,1559'%Q %T %T ON %T.objectPHID = %Q1560AND %T.indexKey = %s',1561$join_type,1562$table,1563$alias,1564$alias,1565$phid_column,1566$alias,1567$index);1568break;1569case 'range':1570list($min, $max) = $constraint['value'];1571if (($min === null) && ($max === null)) {1572// If there's no actual range constraint, just move on.1573break;1574}15751576if ($min === null) {1577$constraint_clause = qsprintf(1578$conn,1579'%T.indexValue <= %d',1580$alias,1581$max);1582} else if ($max === null) {1583$constraint_clause = qsprintf(1584$conn,1585'%T.indexValue >= %d',1586$alias,1587$min);1588} else {1589$constraint_clause = qsprintf(1590$conn,1591'%T.indexValue BETWEEN %d AND %d',1592$alias,1593$min,1594$max);1595}15961597$joins[] = qsprintf(1598$conn,1599'JOIN %T %T ON %T.objectPHID = %Q1600AND %T.indexKey = %s1601AND (%Q)',1602$table,1603$alias,1604$alias,1605$phid_column,1606$alias,1607$index,1608$constraint_clause);1609break;1610default:1611throw new Exception(pht('Unknown constraint condition "%s"!', $cond));1612}1613}16141615$phid_column = $this->getApplicationSearchObjectPHIDColumn($conn);1616$orderable = $this->getOrderableColumns();16171618$vector = $this->getOrderVector();1619foreach ($vector as $order) {1620$spec = $orderable[$order->getOrderKey()];1621if (empty($spec['customfield'])) {1622continue;1623}16241625$table = $spec['customfield.index.table'];1626$alias = $spec['table'];1627$key = $spec['customfield.index.key'];16281629$joins[] = qsprintf(1630$conn,1631'LEFT JOIN %T %T ON %T.objectPHID = %Q1632AND %T.indexKey = %s',1633$table,1634$alias,1635$alias,1636$phid_column,1637$alias,1638$key);1639}16401641if ($joins) {1642return qsprintf($conn, '%LJ', $joins);1643} else {1644return qsprintf($conn, '');1645}1646}16471648/**1649* Construct a WHERE clause appropriate for applying ApplicationSearch1650* constraints.1651*1652* @param AphrontDatabaseConnection Connection executing the query.1653* @return list<string> Where clause parts.1654* @task appsearch1655*/1656protected function buildApplicationSearchWhereClause(1657AphrontDatabaseConnection $conn) {16581659$where = array();16601661foreach ($this->applicationSearchConstraints as $key => $constraint) {1662$alias = $constraint['alias'];1663$cond = $constraint['cond'];1664$type = $constraint['type'];16651666$data_values = $constraint['data'];1667$constraint_values = $constraint['constraints'];16681669$constraint_parts = array();1670switch ($cond) {1671case '=':1672if ($data_values) {1673switch ($type) {1674case 'string':1675$constraint_parts[] = qsprintf(1676$conn,1677'%T.indexValue IN (%Ls)',1678$alias,1679$data_values);1680break;1681case 'int':1682$constraint_parts[] = qsprintf(1683$conn,1684'%T.indexValue IN (%Ld)',1685$alias,1686$data_values);1687break;1688default:1689throw new Exception(pht('Unknown index type "%s"!', $type));1690}1691}16921693if ($constraint_values) {1694foreach ($constraint_values as $value) {1695$op = $value->getOperator();1696switch ($op) {1697case PhabricatorQueryConstraint::OPERATOR_NULL:1698$constraint_parts[] = qsprintf(1699$conn,1700'%T.indexValue IS NULL',1701$alias);1702break;1703case PhabricatorQueryConstraint::OPERATOR_ANY:1704$constraint_parts[] = qsprintf(1705$conn,1706'%T.indexValue IS NOT NULL',1707$alias);1708break;1709default:1710throw new Exception(1711pht(1712'No support for applying operator "%s" against '.1713'index of type "%s".',1714$op,1715$type));1716}1717}1718}17191720if ($constraint_parts) {1721$where[] = qsprintf($conn, '%LO', $constraint_parts);1722}1723break;1724}1725}17261727return $where;1728}172917301731/* -( Integration with CustomField )--------------------------------------- */173217331734/**1735* @task customfield1736*/1737protected function getPagingValueMapForCustomFields(1738PhabricatorCustomFieldInterface $object) {17391740// We have to get the current field values on the cursor object.1741$fields = PhabricatorCustomField::getObjectFields(1742$object,1743PhabricatorCustomField::ROLE_APPLICATIONSEARCH);1744$fields->setViewer($this->getViewer());1745$fields->readFieldsFromStorage($object);17461747$map = array();1748foreach ($fields->getFields() as $field) {1749$map['custom:'.$field->getFieldKey()] = $field->getValueForStorage();1750}17511752return $map;1753}175417551756/**1757* @task customfield1758*/1759protected function isCustomFieldOrderKey($key) {1760$prefix = 'custom:';1761return !strncmp($key, $prefix, strlen($prefix));1762}176317641765/* -( Ferret )------------------------------------------------------------- */176617671768public function supportsFerretEngine() {1769$object = $this->newResultObject();1770return ($object instanceof PhabricatorFerretInterface);1771}17721773public function withFerretQuery(1774PhabricatorFerretEngine $engine,1775PhabricatorSavedQuery $query) {17761777if (!$this->supportsFerretEngine()) {1778throw new Exception(1779pht(1780'Query ("%s") does not support the Ferret fulltext engine.',1781get_class($this)));1782}17831784$this->ferretEngine = $engine;1785$this->ferretQuery = $query;17861787return $this;1788}17891790public function getFerretTokens() {1791if (!$this->supportsFerretEngine()) {1792throw new Exception(1793pht(1794'Query ("%s") does not support the Ferret fulltext engine.',1795get_class($this)));1796}17971798return $this->ferretTokens;1799}18001801public function withFerretConstraint(1802PhabricatorFerretEngine $engine,1803array $fulltext_tokens) {18041805if (!$this->supportsFerretEngine()) {1806throw new Exception(1807pht(1808'Query ("%s") does not support the Ferret fulltext engine.',1809get_class($this)));1810}18111812if ($this->ferretEngine) {1813throw new Exception(1814pht(1815'Query may not have multiple fulltext constraints.'));1816}18171818if (!$fulltext_tokens) {1819return $this;1820}18211822$this->ferretEngine = $engine;1823$this->ferretTokens = $fulltext_tokens;18241825$op_absent = PhutilSearchQueryCompiler::OPERATOR_ABSENT;18261827$default_function = $engine->getDefaultFunctionKey();1828$table_map = array();1829$idx = 1;1830foreach ($this->ferretTokens as $fulltext_token) {1831$raw_token = $fulltext_token->getToken();18321833$function = $raw_token->getFunction();1834if ($function === null) {1835$function = $default_function;1836}18371838$function_def = $engine->getFunctionForName($function);18391840// NOTE: The query compiler guarantees that a query can not make a1841// field both "present" and "absent", so it's safe to just use the1842// first operator we encounter to determine whether the table is1843// optional or not.18441845$operator = $raw_token->getOperator();1846$is_optional = ($operator === $op_absent);18471848if (!isset($table_map[$function])) {1849$alias = 'ftfield_'.$idx++;1850$table_map[$function] = array(1851'alias' => $alias,1852'function' => $function_def,1853'optional' => $is_optional,1854);1855}1856}18571858// Join the title field separately so we can rank results.1859$table_map['rank'] = array(1860'alias' => 'ft_rank',1861'function' => $engine->getFunctionForName('title'),18621863// See T13345. Not every document has a title, so we want to LEFT JOIN1864// this table to avoid excluding documents with no title that match1865// the query in other fields.1866'optional' => true,1867);18681869$this->ferretTables = $table_map;18701871return $this;1872}18731874protected function buildFerretSelectClause(AphrontDatabaseConnection $conn) {1875$select = array();18761877if (!$this->supportsFerretEngine()) {1878return $select;1879}18801881if (!$this->hasFerretOrder()) {1882// We only need to SELECT the virtual rank/relevance columns if we're1883// actually sorting the results by rank.1884return $select;1885}18861887if (!$this->ferretEngine) {1888$select[] = qsprintf($conn, '0 AS %T', self::FULLTEXT_RANK);1889$select[] = qsprintf($conn, '0 AS %T', self::FULLTEXT_CREATED);1890$select[] = qsprintf($conn, '0 AS %T', self::FULLTEXT_MODIFIED);1891return $select;1892}18931894$engine = $this->ferretEngine;1895$stemmer = $engine->newStemmer();18961897$op_sub = PhutilSearchQueryCompiler::OPERATOR_SUBSTRING;1898$op_not = PhutilSearchQueryCompiler::OPERATOR_NOT;1899$table_alias = 'ft_rank';19001901$parts = array();1902foreach ($this->ferretTokens as $fulltext_token) {1903$raw_token = $fulltext_token->getToken();1904$value = $raw_token->getValue();19051906if ($raw_token->getOperator() == $op_not) {1907// Ignore "not" terms when ranking, since they aren't useful.1908continue;1909}19101911if ($raw_token->getOperator() == $op_sub) {1912$is_substring = true;1913} else {1914$is_substring = false;1915}19161917if ($is_substring) {1918$parts[] = qsprintf(1919$conn,1920'IF(%T.rawCorpus LIKE %~, 2, 0)',1921$table_alias,1922$value);1923continue;1924}19251926if ($raw_token->isQuoted()) {1927$is_quoted = true;1928$is_stemmed = false;1929} else {1930$is_quoted = false;1931$is_stemmed = true;1932}19331934$term_constraints = array();19351936$term_value = $engine->newTermsCorpus($value);19371938$parts[] = qsprintf(1939$conn,1940'IF(%T.termCorpus LIKE %~, 2, 0)',1941$table_alias,1942$term_value);19431944if ($is_stemmed) {1945$stem_value = $stemmer->stemToken($value);1946$stem_value = $engine->newTermsCorpus($stem_value);19471948$parts[] = qsprintf(1949$conn,1950'IF(%T.normalCorpus LIKE %~, 1, 0)',1951$table_alias,1952$stem_value);1953}1954}19551956$parts[] = qsprintf($conn, '%d', 0);19571958$sum = array_shift($parts);1959foreach ($parts as $part) {1960$sum = qsprintf(1961$conn,1962'%Q + %Q',1963$sum,1964$part);1965}19661967$select[] = qsprintf(1968$conn,1969'%Q AS %T',1970$sum,1971self::FULLTEXT_RANK);19721973// See D20297. We select these as real columns in the result set so that1974// constructions like this will work:1975//1976// ((SELECT ...) UNION (SELECT ...)) ORDER BY ...1977//1978// If the columns aren't part of the result set, the final "ORDER BY" can1979// not act on them.19801981$select[] = qsprintf(1982$conn,1983'ft_doc.epochCreated AS %T',1984self::FULLTEXT_CREATED);19851986$select[] = qsprintf(1987$conn,1988'ft_doc.epochModified AS %T',1989self::FULLTEXT_MODIFIED);19901991return $select;1992}19931994protected function buildFerretJoinClause(AphrontDatabaseConnection $conn) {1995if (!$this->ferretEngine) {1996return array();1997}19981999$op_sub = PhutilSearchQueryCompiler::OPERATOR_SUBSTRING;2000$op_not = PhutilSearchQueryCompiler::OPERATOR_NOT;2001$op_absent = PhutilSearchQueryCompiler::OPERATOR_ABSENT;2002$op_present = PhutilSearchQueryCompiler::OPERATOR_PRESENT;20032004$engine = $this->ferretEngine;2005$stemmer = $engine->newStemmer();20062007$ngram_table = $engine->getNgramsTableName();2008$ngram_engine = $this->getNgramEngine();20092010$flat = array();2011foreach ($this->ferretTokens as $fulltext_token) {2012$raw_token = $fulltext_token->getToken();20132014$operator = $raw_token->getOperator();20152016// If this is a negated term like "-pomegranate", don't join the ngram2017// table since we aren't looking for documents with this term. (We could2018// LEFT JOIN the table and require a NULL row, but this is probably more2019// trouble than it's worth.)2020if ($operator === $op_not) {2021continue;2022}20232024// Neither the "present" or "absent" operators benefit from joining2025// the ngram table.2026if ($operator === $op_absent || $operator === $op_present) {2027continue;2028}20292030$value = $raw_token->getValue();20312032$length = count(phutil_utf8v($value));20332034if ($raw_token->getOperator() == $op_sub) {2035$is_substring = true;2036} else {2037$is_substring = false;2038}20392040// If the user specified a substring query for a substring which is2041// shorter than the ngram length, we can't use the ngram index, so2042// don't do a join. We'll fall back to just doing LIKE on the full2043// corpus.2044if ($is_substring) {2045if ($length < 3) {2046continue;2047}2048}20492050if ($raw_token->isQuoted()) {2051$is_stemmed = false;2052} else {2053$is_stemmed = true;2054}20552056if ($is_substring) {2057$ngrams = $ngram_engine->getSubstringNgramsFromString($value);2058} else {2059$terms_value = $engine->newTermsCorpus($value);2060$ngrams = $ngram_engine->getTermNgramsFromString($terms_value);20612062// If this is a stemmed term, only look for ngrams present in both the2063// unstemmed and stemmed variations.2064if ($is_stemmed) {2065// Trim the boundary space characters so the stemmer recognizes this2066// is (or, at least, may be) a normal word and activates.2067$terms_value = trim($terms_value, ' ');2068$stem_value = $stemmer->stemToken($terms_value);2069$stem_ngrams = $ngram_engine->getTermNgramsFromString($stem_value);2070$ngrams = array_intersect($ngrams, $stem_ngrams);2071}2072}20732074foreach ($ngrams as $ngram) {2075$flat[] = array(2076'table' => $ngram_table,2077'ngram' => $ngram,2078);2079}2080}20812082// Remove common ngrams, like "the", which occur too frequently in2083// documents to be useful in constraining the query. The best ngrams2084// are obscure sequences which occur in very few documents.20852086if ($flat) {2087$common_ngrams = queryfx_all(2088$conn,2089'SELECT ngram FROM %T WHERE ngram IN (%Ls)',2090$engine->getCommonNgramsTableName(),2091ipull($flat, 'ngram'));2092$common_ngrams = ipull($common_ngrams, 'ngram', 'ngram');20932094foreach ($flat as $key => $spec) {2095$ngram = $spec['ngram'];2096if (isset($common_ngrams[$ngram])) {2097unset($flat[$key]);2098continue;2099}21002101// NOTE: MySQL discards trailing whitespace in CHAR(X) columns.2102$trim_ngram = rtrim($ngram, ' ');2103if (isset($common_ngrams[$trim_ngram])) {2104unset($flat[$key]);2105continue;2106}2107}2108}21092110// MySQL only allows us to join a maximum of 61 tables per query. Each2111// ngram is going to cost us a join toward that limit, so if the user2112// specified a very long query string, just pick 16 of the ngrams2113// at random.2114if (count($flat) > 16) {2115shuffle($flat);2116$flat = array_slice($flat, 0, 16);2117}21182119$alias = $this->getPrimaryTableAlias();2120if ($alias) {2121$phid_column = qsprintf($conn, '%T.%T', $alias, 'phid');2122} else {2123$phid_column = qsprintf($conn, '%T', 'phid');2124}21252126$document_table = $engine->getDocumentTableName();2127$field_table = $engine->getFieldTableName();21282129$joins = array();2130$joins[] = qsprintf(2131$conn,2132'JOIN %T ft_doc ON ft_doc.objectPHID = %Q',2133$document_table,2134$phid_column);21352136$idx = 1;2137foreach ($flat as $spec) {2138$table = $spec['table'];2139$ngram = $spec['ngram'];21402141$alias = 'ftngram_'.$idx++;21422143$joins[] = qsprintf(2144$conn,2145'JOIN %T %T ON %T.documentID = ft_doc.id AND %T.ngram = %s',2146$table,2147$alias,2148$alias,2149$alias,2150$ngram);2151}21522153$object = $this->newResultObject();2154if (!$object) {2155throw new Exception(2156pht(2157'Query class ("%s") must define "newResultObject()" to use '.2158'Ferret constraints.',2159get_class($this)));2160}21612162// See T13511. If we have a fulltext query which uses valid field2163// functions, but at least one of the functions applies to a field which2164// the object can never have, the query can never match anything. Detect2165// this and return an empty result set.21662167// (Even if the query is "field is absent" or "field does not contain2168// such-and-such", the interpretation is that these constraints are2169// not meaningful when applied to an object which can never have the2170// field.)21712172$functions = ipull($this->ferretTables, 'function');2173$functions = mpull($functions, null, 'getFerretFunctionName');2174foreach ($functions as $function) {2175if (!$function->supportsObject($object)) {2176throw new PhabricatorEmptyQueryException(2177pht(2178'This query uses a fulltext function which this document '.2179'type does not support.'));2180}2181}21822183foreach ($this->ferretTables as $table) {2184$alias = $table['alias'];21852186if (empty($table['optional'])) {2187$join_type = qsprintf($conn, 'JOIN');2188} else {2189$join_type = qsprintf($conn, 'LEFT JOIN');2190}21912192$joins[] = qsprintf(2193$conn,2194'%Q %T %T ON ft_doc.id = %T.documentID2195AND %T.fieldKey = %s',2196$join_type,2197$field_table,2198$alias,2199$alias,2200$alias,2201$table['function']->getFerretFieldKey());2202}22032204return $joins;2205}22062207protected function buildFerretWhereClause(AphrontDatabaseConnection $conn) {2208if (!$this->ferretEngine) {2209return array();2210}22112212$engine = $this->ferretEngine;2213$stemmer = $engine->newStemmer();2214$table_map = $this->ferretTables;22152216$op_sub = PhutilSearchQueryCompiler::OPERATOR_SUBSTRING;2217$op_not = PhutilSearchQueryCompiler::OPERATOR_NOT;2218$op_exact = PhutilSearchQueryCompiler::OPERATOR_EXACT;2219$op_absent = PhutilSearchQueryCompiler::OPERATOR_ABSENT;2220$op_present = PhutilSearchQueryCompiler::OPERATOR_PRESENT;22212222$where = array();2223$default_function = $engine->getDefaultFunctionKey();2224foreach ($this->ferretTokens as $fulltext_token) {2225$raw_token = $fulltext_token->getToken();2226$value = $raw_token->getValue();22272228$function = $raw_token->getFunction();2229if ($function === null) {2230$function = $default_function;2231}22322233$operator = $raw_token->getOperator();22342235$table_alias = $table_map[$function]['alias'];22362237// If this is a "field is present" operator, we've already implicitly2238// guaranteed this by JOINing the table. We don't need to do any2239// more work.2240$is_present = ($operator === $op_present);2241if ($is_present) {2242continue;2243}22442245// If this is a "field is absent" operator, we just want documents2246// which failed to match to a row when we LEFT JOINed the table. This2247// means there's no index for the field.2248$is_absent = ($operator === $op_absent);2249if ($is_absent) {2250$where[] = qsprintf(2251$conn,2252'(%T.rawCorpus IS NULL)',2253$table_alias);2254continue;2255}22562257$is_not = ($operator === $op_not);22582259if ($operator == $op_sub) {2260$is_substring = true;2261} else {2262$is_substring = false;2263}22642265// If we're doing exact search, just test the raw corpus.2266$is_exact = ($operator === $op_exact);2267if ($is_exact) {2268if ($is_not) {2269$where[] = qsprintf(2270$conn,2271'(%T.rawCorpus != %s)',2272$table_alias,2273$value);2274} else {2275$where[] = qsprintf(2276$conn,2277'(%T.rawCorpus = %s)',2278$table_alias,2279$value);2280}2281continue;2282}22832284// If we're doing substring search, we just match against the raw corpus2285// and we're done.2286if ($is_substring) {2287if ($is_not) {2288$where[] = qsprintf(2289$conn,2290'(%T.rawCorpus NOT LIKE %~)',2291$table_alias,2292$value);2293} else {2294$where[] = qsprintf(2295$conn,2296'(%T.rawCorpus LIKE %~)',2297$table_alias,2298$value);2299}2300continue;2301}23022303// Otherwise, we need to match against the term corpus and the normal2304// corpus, so that searching for "raw" does not find "strawberry".2305if ($raw_token->isQuoted()) {2306$is_quoted = true;2307$is_stemmed = false;2308} else {2309$is_quoted = false;2310$is_stemmed = true;2311}23122313// Never stem negated queries, since this can exclude results users2314// did not mean to exclude and generally confuse things.2315if ($is_not) {2316$is_stemmed = false;2317}23182319$term_constraints = array();23202321$term_value = $engine->newTermsCorpus($value);2322if ($is_not) {2323$term_constraints[] = qsprintf(2324$conn,2325'(%T.termCorpus NOT LIKE %~)',2326$table_alias,2327$term_value);2328} else {2329$term_constraints[] = qsprintf(2330$conn,2331'(%T.termCorpus LIKE %~)',2332$table_alias,2333$term_value);2334}23352336if ($is_stemmed) {2337$stem_value = $stemmer->stemToken($value);2338$stem_value = $engine->newTermsCorpus($stem_value);23392340$term_constraints[] = qsprintf(2341$conn,2342'(%T.normalCorpus LIKE %~)',2343$table_alias,2344$stem_value);2345}23462347if ($is_not) {2348$where[] = qsprintf(2349$conn,2350'%LA',2351$term_constraints);2352} else if ($is_quoted) {2353$where[] = qsprintf(2354$conn,2355'(%T.rawCorpus LIKE %~ AND %LO)',2356$table_alias,2357$value,2358$term_constraints);2359} else {2360$where[] = qsprintf(2361$conn,2362'%LO',2363$term_constraints);2364}2365}23662367if ($this->ferretQuery) {2368$query = $this->ferretQuery;23692370$author_phids = $query->getParameter('authorPHIDs');2371if ($author_phids) {2372$where[] = qsprintf(2373$conn,2374'ft_doc.authorPHID IN (%Ls)',2375$author_phids);2376}23772378$with_unowned = $query->getParameter('withUnowned');2379$with_any = $query->getParameter('withAnyOwner');23802381if ($with_any && $with_unowned) {2382throw new PhabricatorEmptyQueryException(2383pht(2384'This query matches only unowned documents owned by anyone, '.2385'which is impossible.'));2386}23872388$owner_phids = $query->getParameter('ownerPHIDs');2389if ($owner_phids && !$with_any) {2390if ($with_unowned) {2391$where[] = qsprintf(2392$conn,2393'ft_doc.ownerPHID IN (%Ls) OR ft_doc.ownerPHID IS NULL',2394$owner_phids);2395} else {2396$where[] = qsprintf(2397$conn,2398'ft_doc.ownerPHID IN (%Ls)',2399$owner_phids);2400}2401} else if ($with_unowned) {2402$where[] = qsprintf(2403$conn,2404'ft_doc.ownerPHID IS NULL');2405}24062407if ($with_any) {2408$where[] = qsprintf(2409$conn,2410'ft_doc.ownerPHID IS NOT NULL');2411}24122413$rel_open = PhabricatorSearchRelationship::RELATIONSHIP_OPEN;24142415$statuses = $query->getParameter('statuses');2416$is_closed = null;2417if ($statuses) {2418$statuses = array_fuse($statuses);2419if (count($statuses) == 1) {2420if (isset($statuses[$rel_open])) {2421$is_closed = 0;2422} else {2423$is_closed = 1;2424}2425}2426}24272428if ($is_closed !== null) {2429$where[] = qsprintf(2430$conn,2431'ft_doc.isClosed = %d',2432$is_closed);2433}2434}24352436return $where;2437}24382439protected function shouldGroupFerretResultRows() {2440return (bool)$this->ferretTokens;2441}244224432444/* -( Ngrams )------------------------------------------------------------- */244524462447protected function withNgramsConstraint(2448PhabricatorSearchNgrams $index,2449$value) {24502451if (strlen($value)) {2452$this->ngrams[] = array(2453'index' => $index,2454'value' => $value,2455'length' => count(phutil_utf8v($value)),2456);2457}24582459return $this;2460}246124622463protected function buildNgramsJoinClause(AphrontDatabaseConnection $conn) {2464$ngram_engine = $this->getNgramEngine();24652466$flat = array();2467foreach ($this->ngrams as $spec) {2468$length = $spec['length'];24692470if ($length < 3) {2471continue;2472}24732474$index = $spec['index'];2475$value = $spec['value'];24762477$ngrams = $ngram_engine->getSubstringNgramsFromString($value);24782479foreach ($ngrams as $ngram) {2480$flat[] = array(2481'table' => $index->getTableName(),2482'ngram' => $ngram,2483);2484}2485}24862487if (!$flat) {2488return array();2489}24902491// MySQL only allows us to join a maximum of 61 tables per query. Each2492// ngram is going to cost us a join toward that limit, so if the user2493// specified a very long query string, just pick 16 of the ngrams2494// at random.2495if (count($flat) > 16) {2496shuffle($flat);2497$flat = array_slice($flat, 0, 16);2498}24992500$alias = $this->getPrimaryTableAlias();2501if ($alias) {2502$id_column = qsprintf($conn, '%T.%T', $alias, 'id');2503} else {2504$id_column = qsprintf($conn, '%T', 'id');2505}25062507$idx = 1;2508$joins = array();2509foreach ($flat as $spec) {2510$table = $spec['table'];2511$ngram = $spec['ngram'];25122513$alias = 'ngm'.$idx++;25142515$joins[] = qsprintf(2516$conn,2517'JOIN %T %T ON %T.objectID = %Q AND %T.ngram = %s',2518$table,2519$alias,2520$alias,2521$id_column,2522$alias,2523$ngram);2524}25252526return $joins;2527}252825292530protected function buildNgramsWhereClause(AphrontDatabaseConnection $conn) {2531$where = array();25322533$ngram_engine = $this->getNgramEngine();25342535foreach ($this->ngrams as $ngram) {2536$index = $ngram['index'];2537$value = $ngram['value'];25382539$column = $index->getColumnName();2540$alias = $this->getPrimaryTableAlias();2541if ($alias) {2542$column = qsprintf($conn, '%T.%T', $alias, $column);2543} else {2544$column = qsprintf($conn, '%T', $column);2545}25462547$tokens = $ngram_engine->tokenizeNgramString($value);25482549foreach ($tokens as $token) {2550$where[] = qsprintf(2551$conn,2552'%Q LIKE %~',2553$column,2554$token);2555}2556}25572558return $where;2559}256025612562protected function shouldGroupNgramResultRows() {2563return (bool)$this->ngrams;2564}25652566private function getNgramEngine() {2567if (!$this->ngramEngine) {2568$this->ngramEngine = new PhabricatorSearchNgramEngine();2569}25702571return $this->ngramEngine;2572}257325742575/* -( Edge Logic )--------------------------------------------------------- */257625772578/**2579* Convenience method for specifying edge logic constraints with a list of2580* PHIDs.2581*2582* @param const Edge constant.2583* @param const Constraint operator.2584* @param list<phid> List of PHIDs.2585* @return this2586* @task edgelogic2587*/2588public function withEdgeLogicPHIDs($edge_type, $operator, array $phids) {2589$constraints = array();2590foreach ($phids as $phid) {2591$constraints[] = new PhabricatorQueryConstraint($operator, $phid);2592}25932594return $this->withEdgeLogicConstraints($edge_type, $constraints);2595}259625972598/**2599* @return this2600* @task edgelogic2601*/2602public function withEdgeLogicConstraints($edge_type, array $constraints) {2603assert_instances_of($constraints, 'PhabricatorQueryConstraint');26042605$constraints = mgroup($constraints, 'getOperator');2606foreach ($constraints as $operator => $list) {2607foreach ($list as $item) {2608$this->edgeLogicConstraints[$edge_type][$operator][] = $item;2609}2610}26112612$this->edgeLogicConstraintsAreValid = false;26132614return $this;2615}261626172618/**2619* @task edgelogic2620*/2621public function buildEdgeLogicSelectClause(AphrontDatabaseConnection $conn) {2622$select = array();26232624$this->validateEdgeLogicConstraints();26252626foreach ($this->edgeLogicConstraints as $type => $constraints) {2627foreach ($constraints as $operator => $list) {2628$alias = $this->getEdgeLogicTableAlias($operator, $type);2629switch ($operator) {2630case PhabricatorQueryConstraint::OPERATOR_AND:2631if (count($list) > 1) {2632$select[] = qsprintf(2633$conn,2634'COUNT(DISTINCT(%T.dst)) %T',2635$alias,2636$this->buildEdgeLogicTableAliasCount($alias));2637}2638break;2639case PhabricatorQueryConstraint::OPERATOR_ANCESTOR:2640// This is tricky. We have a query which specifies multiple2641// projects, each of which may have an arbitrarily large number2642// of descendants.26432644// Suppose the projects are "Engineering" and "Operations", and2645// "Engineering" has subprojects X, Y and Z.26462647// We first use `FIELD(dst, X, Y, Z)` to produce a 0 if a row2648// is not part of Engineering at all, or some number other than2649// 0 if it is.26502651// Then we use `IF(..., idx, NULL)` to convert the 0 to a NULL and2652// any other value to an index (say, 1) for the ancestor.26532654// We build these up for every ancestor, then use `COALESCE(...)`2655// to select the non-null one, giving us an ancestor which this2656// row is a member of.26572658// From there, we use `COUNT(DISTINCT(...))` to make sure that2659// each result row is a member of all ancestors.2660if (count($list) > 1) {2661$idx = 1;2662$parts = array();2663foreach ($list as $constraint) {2664$parts[] = qsprintf(2665$conn,2666'IF(FIELD(%T.dst, %Ls) != 0, %d, NULL)',2667$alias,2668(array)$constraint->getValue(),2669$idx++);2670}2671$parts = qsprintf($conn, '%LQ', $parts);26722673$select[] = qsprintf(2674$conn,2675'COUNT(DISTINCT(COALESCE(%Q))) %T',2676$parts,2677$this->buildEdgeLogicTableAliasAncestor($alias));2678}2679break;2680default:2681break;2682}2683}2684}26852686return $select;2687}268826892690/**2691* @task edgelogic2692*/2693public function buildEdgeLogicJoinClause(AphrontDatabaseConnection $conn) {2694$edge_table = PhabricatorEdgeConfig::TABLE_NAME_EDGE;2695$phid_column = $this->getApplicationSearchObjectPHIDColumn($conn);26962697$joins = array();2698foreach ($this->edgeLogicConstraints as $type => $constraints) {26992700$op_null = PhabricatorQueryConstraint::OPERATOR_NULL;2701$has_null = isset($constraints[$op_null]);27022703// If we're going to process an only() operator, build a list of the2704// acceptable set of PHIDs first. We'll only match results which have2705// no edges to any other PHIDs.2706$all_phids = array();2707if (isset($constraints[PhabricatorQueryConstraint::OPERATOR_ONLY])) {2708foreach ($constraints as $operator => $list) {2709switch ($operator) {2710case PhabricatorQueryConstraint::OPERATOR_ANCESTOR:2711case PhabricatorQueryConstraint::OPERATOR_AND:2712case PhabricatorQueryConstraint::OPERATOR_OR:2713foreach ($list as $constraint) {2714$value = (array)$constraint->getValue();2715foreach ($value as $v) {2716$all_phids[$v] = $v;2717}2718}2719break;2720}2721}2722}27232724foreach ($constraints as $operator => $list) {2725$alias = $this->getEdgeLogicTableAlias($operator, $type);27262727$phids = array();2728foreach ($list as $constraint) {2729$value = (array)$constraint->getValue();2730foreach ($value as $v) {2731$phids[$v] = $v;2732}2733}2734$phids = array_keys($phids);27352736switch ($operator) {2737case PhabricatorQueryConstraint::OPERATOR_NOT:2738$joins[] = qsprintf(2739$conn,2740'LEFT JOIN %T %T ON %Q = %T.src AND %T.type = %d2741AND %T.dst IN (%Ls)',2742$edge_table,2743$alias,2744$phid_column,2745$alias,2746$alias,2747$type,2748$alias,2749$phids);2750break;2751case PhabricatorQueryConstraint::OPERATOR_ANCESTOR:2752case PhabricatorQueryConstraint::OPERATOR_AND:2753case PhabricatorQueryConstraint::OPERATOR_OR:2754// If we're including results with no matches, we have to degrade2755// this to a LEFT join. We'll use WHERE to select matching rows2756// later.2757if ($has_null) {2758$join_type = qsprintf($conn, 'LEFT');2759} else {2760$join_type = qsprintf($conn, '');2761}27622763$joins[] = qsprintf(2764$conn,2765'%Q JOIN %T %T ON %Q = %T.src AND %T.type = %d2766AND %T.dst IN (%Ls)',2767$join_type,2768$edge_table,2769$alias,2770$phid_column,2771$alias,2772$alias,2773$type,2774$alias,2775$phids);2776break;2777case PhabricatorQueryConstraint::OPERATOR_NULL:2778$joins[] = qsprintf(2779$conn,2780'LEFT JOIN %T %T ON %Q = %T.src AND %T.type = %d',2781$edge_table,2782$alias,2783$phid_column,2784$alias,2785$alias,2786$type);2787break;2788case PhabricatorQueryConstraint::OPERATOR_ONLY:2789$joins[] = qsprintf(2790$conn,2791'LEFT JOIN %T %T ON %Q = %T.src AND %T.type = %d2792AND %T.dst NOT IN (%Ls)',2793$edge_table,2794$alias,2795$phid_column,2796$alias,2797$alias,2798$type,2799$alias,2800$all_phids);2801break;2802}2803}2804}28052806return $joins;2807}280828092810/**2811* @task edgelogic2812*/2813public function buildEdgeLogicWhereClause(AphrontDatabaseConnection $conn) {2814$where = array();28152816foreach ($this->edgeLogicConstraints as $type => $constraints) {28172818$full = array();2819$null = array();28202821$op_null = PhabricatorQueryConstraint::OPERATOR_NULL;2822$has_null = isset($constraints[$op_null]);28232824foreach ($constraints as $operator => $list) {2825$alias = $this->getEdgeLogicTableAlias($operator, $type);2826switch ($operator) {2827case PhabricatorQueryConstraint::OPERATOR_NOT:2828case PhabricatorQueryConstraint::OPERATOR_ONLY:2829$full[] = qsprintf(2830$conn,2831'%T.dst IS NULL',2832$alias);2833break;2834case PhabricatorQueryConstraint::OPERATOR_AND:2835case PhabricatorQueryConstraint::OPERATOR_OR:2836if ($has_null) {2837$full[] = qsprintf(2838$conn,2839'%T.dst IS NOT NULL',2840$alias);2841}2842break;2843case PhabricatorQueryConstraint::OPERATOR_NULL:2844$null[] = qsprintf(2845$conn,2846'%T.dst IS NULL',2847$alias);2848break;2849}2850}28512852if ($full && $null) {2853$where[] = qsprintf($conn, '(%LA OR %LA)', $full, $null);2854} else if ($full) {2855foreach ($full as $condition) {2856$where[] = $condition;2857}2858} else if ($null) {2859foreach ($null as $condition) {2860$where[] = $condition;2861}2862}2863}28642865return $where;2866}286728682869/**2870* @task edgelogic2871*/2872public function buildEdgeLogicHavingClause(AphrontDatabaseConnection $conn) {2873$having = array();28742875foreach ($this->edgeLogicConstraints as $type => $constraints) {2876foreach ($constraints as $operator => $list) {2877$alias = $this->getEdgeLogicTableAlias($operator, $type);2878switch ($operator) {2879case PhabricatorQueryConstraint::OPERATOR_AND:2880if (count($list) > 1) {2881$having[] = qsprintf(2882$conn,2883'%T = %d',2884$this->buildEdgeLogicTableAliasCount($alias),2885count($list));2886}2887break;2888case PhabricatorQueryConstraint::OPERATOR_ANCESTOR:2889if (count($list) > 1) {2890$having[] = qsprintf(2891$conn,2892'%T = %d',2893$this->buildEdgeLogicTableAliasAncestor($alias),2894count($list));2895}2896break;2897}2898}2899}29002901return $having;2902}290329042905/**2906* @task edgelogic2907*/2908public function shouldGroupEdgeLogicResultRows() {2909foreach ($this->edgeLogicConstraints as $type => $constraints) {2910foreach ($constraints as $operator => $list) {2911switch ($operator) {2912case PhabricatorQueryConstraint::OPERATOR_NOT:2913case PhabricatorQueryConstraint::OPERATOR_AND:2914case PhabricatorQueryConstraint::OPERATOR_OR:2915if (count($list) > 1) {2916return true;2917}2918break;2919case PhabricatorQueryConstraint::OPERATOR_ANCESTOR:2920// NOTE: We must always group query results rows when using an2921// "ANCESTOR" operator because a single task may be related to2922// two different descendants of a particular ancestor. For2923// discussion, see T12753.2924return true;2925case PhabricatorQueryConstraint::OPERATOR_NULL:2926case PhabricatorQueryConstraint::OPERATOR_ONLY:2927return true;2928}2929}2930}29312932return false;2933}293429352936/**2937* @task edgelogic2938*/2939private function getEdgeLogicTableAlias($operator, $type) {2940return 'edgelogic_'.$operator.'_'.$type;2941}294229432944/**2945* @task edgelogic2946*/2947private function buildEdgeLogicTableAliasCount($alias) {2948return $alias.'_count';2949}29502951/**2952* @task edgelogic2953*/2954private function buildEdgeLogicTableAliasAncestor($alias) {2955return $alias.'_ancestor';2956}295729582959/**2960* Select certain edge logic constraint values.2961*2962* @task edgelogic2963*/2964protected function getEdgeLogicValues(2965array $edge_types,2966array $operators) {29672968$values = array();29692970$constraint_lists = $this->edgeLogicConstraints;2971if ($edge_types) {2972$constraint_lists = array_select_keys($constraint_lists, $edge_types);2973}29742975foreach ($constraint_lists as $type => $constraints) {2976if ($operators) {2977$constraints = array_select_keys($constraints, $operators);2978}2979foreach ($constraints as $operator => $list) {2980foreach ($list as $constraint) {2981$value = (array)$constraint->getValue();2982foreach ($value as $v) {2983$values[] = $v;2984}2985}2986}2987}29882989return $values;2990}299129922993/**2994* Validate edge logic constraints for the query.2995*2996* @return this2997* @task edgelogic2998*/2999private function validateEdgeLogicConstraints() {3000if ($this->edgeLogicConstraintsAreValid) {3001return $this;3002}30033004foreach ($this->edgeLogicConstraints as $type => $constraints) {3005foreach ($constraints as $operator => $list) {3006switch ($operator) {3007case PhabricatorQueryConstraint::OPERATOR_EMPTY:3008throw new PhabricatorEmptyQueryException(3009pht('This query specifies an empty constraint.'));3010}3011}3012}30133014// This should probably be more modular, eventually, but we only do3015// project-based edge logic today.30163017$project_phids = $this->getEdgeLogicValues(3018array(3019PhabricatorProjectObjectHasProjectEdgeType::EDGECONST,3020),3021array(3022PhabricatorQueryConstraint::OPERATOR_AND,3023PhabricatorQueryConstraint::OPERATOR_OR,3024PhabricatorQueryConstraint::OPERATOR_NOT,3025PhabricatorQueryConstraint::OPERATOR_ANCESTOR,3026));3027if ($project_phids) {3028$projects = id(new PhabricatorProjectQuery())3029->setViewer($this->getViewer())3030->setParentQuery($this)3031->withPHIDs($project_phids)3032->execute();3033$projects = mpull($projects, null, 'getPHID');3034foreach ($project_phids as $phid) {3035if (empty($projects[$phid])) {3036throw new PhabricatorEmptyQueryException(3037pht(3038'This query is constrained by a project you do not have '.3039'permission to see.'));3040}3041}3042}30433044$op_and = PhabricatorQueryConstraint::OPERATOR_AND;3045$op_or = PhabricatorQueryConstraint::OPERATOR_OR;3046$op_ancestor = PhabricatorQueryConstraint::OPERATOR_ANCESTOR;30473048foreach ($this->edgeLogicConstraints as $type => $constraints) {3049foreach ($constraints as $operator => $list) {3050switch ($operator) {3051case PhabricatorQueryConstraint::OPERATOR_ONLY:3052if (count($list) > 1) {3053throw new PhabricatorEmptyQueryException(3054pht(3055'This query specifies only() more than once.'));3056}30573058$have_and = idx($constraints, $op_and);3059$have_or = idx($constraints, $op_or);3060$have_ancestor = idx($constraints, $op_ancestor);3061if (!$have_and && !$have_or && !$have_ancestor) {3062throw new PhabricatorEmptyQueryException(3063pht(3064'This query specifies only(), but no other constraints '.3065'which it can apply to.'));3066}3067break;3068}3069}3070}30713072$this->edgeLogicConstraintsAreValid = true;30733074return $this;3075}307630773078/* -( Spaces )------------------------------------------------------------- */307930803081/**3082* Constrain the query to return results from only specific Spaces.3083*3084* Pass a list of Space PHIDs, or `null` to represent the default space. Only3085* results in those Spaces will be returned.3086*3087* Queries are always constrained to include only results from spaces the3088* viewer has access to.3089*3090* @param list<phid|null>3091* @task spaces3092*/3093public function withSpacePHIDs(array $space_phids) {3094$object = $this->newResultObject();30953096if (!$object) {3097throw new Exception(3098pht(3099'This query (of class "%s") does not implement newResultObject(), '.3100'but must implement this method to enable support for Spaces.',3101get_class($this)));3102}31033104if (!($object instanceof PhabricatorSpacesInterface)) {3105throw new Exception(3106pht(3107'This query (of class "%s") returned an object of class "%s" from '.3108'getNewResultObject(), but it does not implement the required '.3109'interface ("%s"). Objects must implement this interface to enable '.3110'Spaces support.',3111get_class($this),3112get_class($object),3113'PhabricatorSpacesInterface'));3114}31153116$this->spacePHIDs = $space_phids;31173118return $this;3119}31203121public function withSpaceIsArchived($archived) {3122$this->spaceIsArchived = $archived;3123return $this;3124}312531263127/**3128* Constrain the query to include only results in valid Spaces.3129*3130* This method builds part of a WHERE clause which considers the spaces the3131* viewer has access to see with any explicit constraint on spaces added by3132* @{method:withSpacePHIDs}.3133*3134* @param AphrontDatabaseConnection Database connection.3135* @return string Part of a WHERE clause.3136* @task spaces3137*/3138private function buildSpacesWhereClause(AphrontDatabaseConnection $conn) {3139$object = $this->newResultObject();3140if (!$object) {3141return null;3142}31433144if (!($object instanceof PhabricatorSpacesInterface)) {3145return null;3146}31473148$viewer = $this->getViewer();31493150// If we have an omnipotent viewer and no formal space constraints, don't3151// emit a clause. This primarily enables older migrations to run cleanly,3152// without fataling because they try to match a `spacePHID` column which3153// does not exist yet. See T8743, T8746.3154if ($viewer->isOmnipotent()) {3155if ($this->spaceIsArchived === null && $this->spacePHIDs === null) {3156return null;3157}3158}31593160// See T13240. If this query raises policy exceptions, don't filter objects3161// in the MySQL layer. We want them to reach the application layer so we3162// can reject them and raise an exception.3163if ($this->shouldRaisePolicyExceptions()) {3164return null;3165}31663167$space_phids = array();3168$include_null = false;31693170$all = PhabricatorSpacesNamespaceQuery::getAllSpaces();3171if (!$all) {3172// If there are no spaces at all, implicitly give the viewer access to3173// the default space.3174$include_null = true;3175} else {3176// Otherwise, give them access to the spaces they have permission to3177// see.3178$viewer_spaces = PhabricatorSpacesNamespaceQuery::getViewerSpaces(3179$viewer);3180foreach ($viewer_spaces as $viewer_space) {3181if ($this->spaceIsArchived !== null) {3182if ($viewer_space->getIsArchived() != $this->spaceIsArchived) {3183continue;3184}3185}3186$phid = $viewer_space->getPHID();3187$space_phids[$phid] = $phid;3188if ($viewer_space->getIsDefaultNamespace()) {3189$include_null = true;3190}3191}3192}31933194// If we have additional explicit constraints, evaluate them now.3195if ($this->spacePHIDs !== null) {3196$explicit = array();3197$explicit_null = false;3198foreach ($this->spacePHIDs as $phid) {3199if ($phid === null) {3200$space = PhabricatorSpacesNamespaceQuery::getDefaultSpace();3201} else {3202$space = idx($all, $phid);3203}32043205if ($space) {3206$phid = $space->getPHID();3207$explicit[$phid] = $phid;3208if ($space->getIsDefaultNamespace()) {3209$explicit_null = true;3210}3211}3212}32133214// If the viewer can see the default space but it isn't on the explicit3215// list of spaces to query, don't match it.3216if ($include_null && !$explicit_null) {3217$include_null = false;3218}32193220// Include only the spaces common to the viewer and the constraints.3221$space_phids = array_intersect_key($space_phids, $explicit);3222}32233224if (!$space_phids && !$include_null) {3225if ($this->spacePHIDs === null) {3226throw new PhabricatorEmptyQueryException(3227pht('You do not have access to any spaces.'));3228} else {3229throw new PhabricatorEmptyQueryException(3230pht(3231'You do not have access to any of the spaces this query '.3232'is constrained to.'));3233}3234}32353236$alias = $this->getPrimaryTableAlias();3237if ($alias) {3238$col = qsprintf($conn, '%T.spacePHID', $alias);3239} else {3240$col = qsprintf($conn, 'spacePHID');3241}32423243if ($space_phids && $include_null) {3244return qsprintf(3245$conn,3246'(%Q IN (%Ls) OR %Q IS NULL)',3247$col,3248$space_phids,3249$col);3250} else if ($space_phids) {3251return qsprintf(3252$conn,3253'%Q IN (%Ls)',3254$col,3255$space_phids);3256} else {3257return qsprintf(3258$conn,3259'%Q IS NULL',3260$col);3261}3262}32633264private function hasFerretOrder() {3265$vector = $this->getOrderVector();32663267if ($vector->containsKey('rank')) {3268return true;3269}32703271if ($vector->containsKey('fulltext-created')) {3272return true;3273}32743275if ($vector->containsKey('fulltext-modified')) {3276return true;3277}32783279return false;3280}32813282}328332843285