diff --git a/src/applications/calendar/query/PhabricatorCalendarEventQuery.php b/src/applications/calendar/query/PhabricatorCalendarEventQuery.php index 3eac223018..e735e8220f 100644 --- a/src/applications/calendar/query/PhabricatorCalendarEventQuery.php +++ b/src/applications/calendar/query/PhabricatorCalendarEventQuery.php @@ -1,479 +1,528 @@ generateGhosts = $generate_ghosts; return $this; } public function withIDs(array $ids) { $this->ids = $ids; return $this; } public function withPHIDs(array $phids) { $this->phids = $phids; return $this; } public function withDateRange($begin, $end) { $this->rangeBegin = $begin; $this->rangeEnd = $end; return $this; } public function withInvitedPHIDs(array $phids) { $this->inviteePHIDs = $phids; return $this; } public function withCreatorPHIDs(array $phids) { $this->creatorPHIDs = $phids; return $this; } public function withIsCancelled($is_cancelled) { $this->isCancelled = $is_cancelled; return $this; } public function withIsStub($is_stub) { $this->isStub = $is_stub; return $this; } public function withEventsWithNoParent($events_with_no_parent) { $this->eventsWithNoParent = $events_with_no_parent; return $this; } public function withInstanceSequencePairs(array $pairs) { $this->instanceSequencePairs = $pairs; return $this; } protected function getDefaultOrderVector() { return array('start', 'id'); } + public function getBuiltinOrders() { + return array( + 'start' => array( + 'vector' => array('start', 'id'), + 'name' => pht('Event Start'), + ), + ) + parent::getBuiltinOrders(); + } + public function getOrderableColumns() { return array( 'start' => array( 'table' => $this->getPrimaryTableAlias(), 'column' => 'dateFrom', 'reverse' => true, 'type' => 'int', 'unique' => false, ), ) + parent::getOrderableColumns(); } protected function getPagingValueMap($cursor, array $keys) { $event = $this->loadCursorObject($cursor); return array( 'start' => $event->getViewerDateFrom(), 'id' => $event->getID(), ); } + protected function shouldLimitResults() { + // When generating ghosts, we can't rely on database ordering because + // MySQL can't predict the ghost start times. We'll just load all matching + // events, then generate results from there. + if ($this->generateGhosts) { + return false; + } + + return true; + } + protected function loadPage() { $events = $this->loadStandardPage($this->newResultObject()); $viewer = $this->getViewer(); foreach ($events as $event) { $event->applyViewerTimezone($viewer); } if (!$this->generateGhosts) { return $events; } - $enforced_end = null; $raw_limit = $this->getRawResultLimit(); if (!$raw_limit && !$this->rangeEnd) { throw new Exception( pht( 'Event queries which generate ghost events must include either a '. 'result limit or an end date, because they may otherwise generate '. 'an infinite number of results. This query has neither.')); } foreach ($events as $key => $event) { $sequence_start = 0; $sequence_end = null; - $duration = $event->getDuration(); $end = null; $instance_of = $event->getInstanceOfEventPHID(); if ($instance_of == null && $this->isCancelled !== null) { if ($event->getIsCancelled() != $this->isCancelled) { unset($events[$key]); continue; } } + } - if ($event->getIsRecurring() && $instance_of == null) { - $frequency = $event->getFrequencyUnit(); - $modify_key = '+1 '.$frequency; + // Pull out all of the parents first. We may discard them as we begin + // generating ghost events, but we still want to process all of them. + $parents = array(); + foreach ($events as $key => $event) { + if ($event->isParentEvent()) { + $parents[$key] = $event; + } + } - if (($this->rangeBegin !== null) && - ($this->rangeBegin > $event->getViewerDateFrom())) { - $max_date = $this->rangeBegin - $duration; - $date = $event->getViewerDateFrom(); - $datetime = PhabricatorTime::getDateTimeFromEpoch($date, $viewer); + // Now that we've picked out all the parent events, we can immediately + // discard anything outside of the time window. + $events = $this->getEventsInRange($events); - while ($date < $max_date) { - // TODO: optimize this to not loop through all off-screen events - $sequence_start++; - $datetime = PhabricatorTime::getDateTimeFromEpoch($date, $viewer); - $date = $datetime->modify($modify_key)->format('U'); - } + $enforced_end = null; + foreach ($parents as $key => $event) { + $sequence_start = 0; + $sequence_end = null; + $start = null; - $start = $this->rangeBegin; - } else { - $start = $event->getViewerDateFrom() - $duration; - } + $duration = $event->getDuration(); - $date = $start; + $frequency = $event->getFrequencyUnit(); + $modify_key = '+1 '.$frequency; + + if (($this->rangeBegin !== null) && + ($this->rangeBegin > $event->getViewerDateFrom())) { + $max_date = $this->rangeBegin - $duration; + $date = $event->getViewerDateFrom(); $datetime = PhabricatorTime::getDateTimeFromEpoch($date, $viewer); - if (($this->rangeEnd && $event->getRecurrenceEndDate()) && - $this->rangeEnd < $event->getRecurrenceEndDate()) { - $end = $this->rangeEnd; - } else if ($event->getRecurrenceEndDate()) { - $end = $event->getRecurrenceEndDate(); - } else if ($this->rangeEnd) { - $end = $this->rangeEnd; - } else if ($enforced_end) { - if ($end) { - $end = min($end, $enforced_end); - } else { - $end = $enforced_end; - } + while ($date < $max_date) { + // TODO: optimize this to not loop through all off-screen events + $sequence_start++; + $datetime = PhabricatorTime::getDateTimeFromEpoch($date, $viewer); + $date = $datetime->modify($modify_key)->format('U'); } - if ($end) { - $sequence_end = $sequence_start; - while ($date < $end) { - $sequence_end++; - $datetime->modify($modify_key); - $date = $datetime->format('U'); - if ($sequence_end > $raw_limit + $sequence_start) { - break; - } - } - } else { - $sequence_end = $raw_limit + $sequence_start; - } + $start = $this->rangeBegin; + } else { + $start = $event->getViewerDateFrom() - $duration; + } - $sequence_start = max(1, $sequence_start); + $date = $start; + $datetime = PhabricatorTime::getDateTimeFromEpoch($date, $viewer); - for ($index = $sequence_start; $index < $sequence_end; $index++) { - $events[] = $event->newGhost($viewer, $index); - } + // Select the minimum end time we need to generate events until. + $end_times = array(); + if ($this->rangeEnd) { + $end_times[] = $this->rangeEnd; + } - // NOTE: We're slicing results every time because this makes it cheaper - // to generate future ghosts. If we already have 100 events that occur - // before July 1, we know we never need to generate ghosts after that - // because they couldn't possibly ever appear in the result set. + if ($event->getRecurrenceEndDate()) { + $end_times[] = $event->getRecurrenceEndDate(); + } - if ($raw_limit) { - if (count($events) >= $raw_limit) { - $events = msort($events, 'getViewerDateFrom'); - $events = array_slice($events, 0, $raw_limit, true); - $enforced_end = last($events)->getViewerDateFrom(); + if ($enforced_end) { + $end_times[] = $enforced_end; + } + + if ($end_times) { + $end = min($end_times); + $sequence_end = $sequence_start; + while ($date < $end) { + $sequence_end++; + $datetime->modify($modify_key); + $date = $datetime->format('U'); + if ($sequence_end > $raw_limit + $sequence_start) { + break; } } + } else { + $sequence_end = $raw_limit + $sequence_start; + } + + $sequence_start = max(1, $sequence_start); + for ($index = $sequence_start; $index < $sequence_end; $index++) { + $events[] = $event->newGhost($viewer, $index); + } + + // NOTE: We're slicing results every time because this makes it cheaper + // to generate future ghosts. If we already have 100 events that occur + // before July 1, we know we never need to generate ghosts after that + // because they couldn't possibly ever appear in the result set. + + if ($raw_limit) { + if (count($events) > $raw_limit) { + $events = msort($events, 'getViewerDateFrom'); + $events = array_slice($events, 0, $raw_limit, true); + $enforced_end = last($events)->getViewerDateFrom(); + } } } // Now that we're done generating ghost events, we're going to remove any // ghosts that we have concrete events for (or which we can load the // concrete events for). These concrete events are generated when users // edit a ghost, and replace the ghost events. // First, generate a map of all concrete events we // already loaded. We don't need to load these again. $have_pairs = array(); foreach ($events as $event) { if ($event->getIsGhostEvent()) { continue; } $parent_phid = $event->getInstanceOfEventPHID(); $sequence = $event->getSequenceIndex(); $have_pairs[$parent_phid][$sequence] = true; } // Now, generate a map of all events we generated // ghosts for. We need to try to load these if we don't already have them. $map = array(); $parent_pairs = array(); foreach ($events as $key => $event) { if (!$event->getIsGhostEvent()) { continue; } $parent_phid = $event->getInstanceOfEventPHID(); $sequence = $event->getSequenceIndex(); // We already loaded the concrete version of this event, so we can just // throw out the ghost and move on. if (isset($have_pairs[$parent_phid][$sequence])) { unset($events[$key]); continue; } // We didn't load the concrete version of this event, so we need to // try to load it if it exists. $parent_pairs[] = array($parent_phid, $sequence); $map[$parent_phid][$sequence] = $key; } if ($parent_pairs) { $instances = id(new self()) ->setViewer($viewer) ->setParentQuery($this) ->withInstanceSequencePairs($parent_pairs) ->execute(); foreach ($instances as $instance) { $parent_phid = $instance->getInstanceOfEventPHID(); $sequence = $instance->getSequenceIndex(); $indexes = idx($map, $parent_phid); $key = idx($indexes, $sequence); // Replace the ghost with the corresponding concrete event. $events[$key] = $instance; } } + $events = msort($events, 'getViewerDateFrom'); + return $events; } protected function buildJoinClauseParts(AphrontDatabaseConnection $conn_r) { $parts = parent::buildJoinClauseParts($conn_r); + if ($this->inviteePHIDs !== null) { $parts[] = qsprintf( $conn_r, 'JOIN %T invitee ON invitee.eventPHID = event.phid AND invitee.status != %s', id(new PhabricatorCalendarEventInvitee())->getTableName(), PhabricatorCalendarEventInvitee::STATUS_UNINVITED); } + return $parts; } protected function buildWhereClauseParts(AphrontDatabaseConnection $conn) { $where = parent::buildWhereClauseParts($conn); if ($this->ids) { $where[] = qsprintf( $conn, 'event.id IN (%Ld)', $this->ids); } if ($this->phids) { $where[] = qsprintf( $conn, 'event.phid IN (%Ls)', $this->phids); } // NOTE: The date ranges we query for are larger than the requested ranges // because we need to catch all-day events. We'll refine this range later // after adjusting the visible range of events we load. if ($this->rangeBegin) { $where[] = qsprintf( $conn, 'event.dateTo >= %d OR event.isRecurring = 1', $this->rangeBegin - phutil_units('16 hours in seconds')); } if ($this->rangeEnd) { $where[] = qsprintf( $conn, 'event.dateFrom <= %d', $this->rangeEnd + phutil_units('16 hours in seconds')); } if ($this->inviteePHIDs !== null) { $where[] = qsprintf( $conn, 'invitee.inviteePHID IN (%Ls)', $this->inviteePHIDs); } if ($this->creatorPHIDs) { $where[] = qsprintf( $conn, 'event.userPHID IN (%Ls)', $this->creatorPHIDs); } if ($this->isCancelled !== null) { $where[] = qsprintf( $conn, 'event.isCancelled = %d', (int)$this->isCancelled); } if ($this->eventsWithNoParent == true) { $where[] = qsprintf( $conn, 'event.instanceOfEventPHID IS NULL'); } if ($this->instanceSequencePairs !== null) { $sql = array(); foreach ($this->instanceSequencePairs as $pair) { $sql[] = qsprintf( $conn, '(event.instanceOfEventPHID = %s AND event.sequenceIndex = %d)', $pair[0], $pair[1]); } $where[] = qsprintf( $conn, '%Q', implode(' OR ', $sql)); } if ($this->isStub !== null) { $where[] = qsprintf( $conn, 'event.isStub = %d', (int)$this->isStub); } return $where; } protected function getPrimaryTableAlias() { return 'event'; } protected function shouldGroupQueryResultRows() { if ($this->inviteePHIDs !== null) { return true; } return parent::shouldGroupQueryResultRows(); } protected function getApplicationSearchObjectPHIDColumn() { return 'event.phid'; } public function getQueryApplicationClass() { return 'PhabricatorCalendarApplication'; } protected function willFilterPage(array $events) { - $range_start = $this->rangeBegin; - $range_end = $this->rangeEnd; $instance_of_event_phids = array(); $recurring_events = array(); $viewer = $this->getViewer(); - foreach ($events as $key => $event) { - $event_start = $event->getViewerDateFrom(); - $event_end = $event->getViewerDateTo(); - - if ($range_start && $event_end < $range_start) { - unset($events[$key]); - } - if ($range_end && $event_start > $range_end) { - unset($events[$key]); - } - } + $events = $this->getEventsInRange($events); $phids = array(); foreach ($events as $event) { $phids[] = $event->getPHID(); $instance_of = $event->getInstanceOfEventPHID(); if ($instance_of) { $instance_of_event_phids[] = $instance_of; } } if (count($instance_of_event_phids) > 0) { $recurring_events = id(new PhabricatorCalendarEventQuery()) ->setViewer($viewer) ->withPHIDs($instance_of_event_phids) ->withEventsWithNoParent(true) ->execute(); $recurring_events = mpull($recurring_events, null, 'getPHID'); } if ($events) { $invitees = id(new PhabricatorCalendarEventInviteeQuery()) ->setViewer($viewer) ->withEventPHIDs($phids) ->execute(); $invitees = mgroup($invitees, 'getEventPHID'); } else { $invitees = array(); } foreach ($events as $key => $event) { $event_invitees = idx($invitees, $event->getPHID(), array()); $event->attachInvitees($event_invitees); $instance_of = $event->getInstanceOfEventPHID(); if (!$instance_of) { continue; } $parent = idx($recurring_events, $instance_of); // should never get here if (!$parent) { unset($events[$key]); continue; } $event->attachParentEvent($parent); if ($this->isCancelled !== null) { if ($event->getIsCancelled() != $this->isCancelled) { unset($events[$key]); continue; } } } $events = msort($events, 'getViewerDateFrom'); return $events; } + private function getEventsInRange(array $events) { + $range_start = $this->rangeBegin; + $range_end = $this->rangeEnd; + + foreach ($events as $key => $event) { + $event_start = $event->getViewerDateFrom(); + $event_end = $event->getViewerDateTo(); + + if ($range_start && $event_end < $range_start) { + unset($events[$key]); + } + + if ($range_end && $event_start > $range_end) { + unset($events[$key]); + } + } + + return $events; + } + } diff --git a/src/applications/calendar/query/PhabricatorCalendarEventSearchEngine.php b/src/applications/calendar/query/PhabricatorCalendarEventSearchEngine.php index 3465d3ee71..bdff84fc1d 100644 --- a/src/applications/calendar/query/PhabricatorCalendarEventSearchEngine.php +++ b/src/applications/calendar/query/PhabricatorCalendarEventSearchEngine.php @@ -1,591 +1,592 @@ setLabel(pht('Created By')) ->setKey('creatorPHIDs') ->setDatasource(new PhabricatorPeopleUserFunctionDatasource()), id(new PhabricatorSearchDatasourceField()) ->setLabel(pht('Invited')) ->setKey('invitedPHIDs') ->setDatasource(new PhabricatorPeopleUserFunctionDatasource()), id(new PhabricatorSearchDateControlField()) ->setLabel(pht('Occurs After')) ->setKey('rangeStart'), id(new PhabricatorSearchDateControlField()) ->setLabel(pht('Occurs Before')) ->setKey('rangeEnd') ->setAliases(array('rangeEnd')), id(new PhabricatorSearchCheckboxesField()) ->setKey('upcoming') ->setOptions(array( 'upcoming' => pht('Show only upcoming events.'), )), id(new PhabricatorSearchSelectField()) ->setLabel(pht('Cancelled Events')) ->setKey('isCancelled') ->setOptions($this->getCancelledOptions()) ->setDefault('active'), id(new PhabricatorSearchSelectField()) ->setLabel(pht('Display Options')) ->setKey('display') ->setOptions($this->getViewOptions()) ->setDefault('month'), ); } private function getCancelledOptions() { return array( 'active' => pht('Active Events Only'), 'cancelled' => pht('Cancelled Events Only'), 'both' => pht('Both Cancelled and Active Events'), ); } private function getViewOptions() { return array( 'month' => pht('Month View'), 'day' => pht('Day View'), 'list' => pht('List View'), ); } protected function buildQueryFromParameters(array $map) { $query = $this->newQuery(); $viewer = $this->requireViewer(); if ($map['creatorPHIDs']) { $query->withCreatorPHIDs($map['creatorPHIDs']); } if ($map['invitedPHIDs']) { $query->withInvitedPHIDs($map['invitedPHIDs']); } $range_start = $map['rangeStart']; $range_end = $map['rangeEnd']; $display = $map['display']; if ($map['upcoming'] && $map['upcoming'][0] == 'upcoming') { $upcoming = true; } else { $upcoming = false; } list($range_start, $range_end) = $this->getQueryDateRange( $range_start, $range_end, $display, $upcoming); $query->withDateRange($range_start, $range_end); switch ($map['isCancelled']) { case 'active': $query->withIsCancelled(false); break; case 'cancelled': $query->withIsCancelled(true); break; } // Generate ghosts (and ignore stub events) if we aren't querying for // specific events. if (!$map['ids'] && !$map['phids']) { $query ->withIsStub(false) ->setGenerateGhosts(true); } return $query; } private function getQueryDateRange( $start_date_wild, $end_date_wild, $display, $upcoming) { $start_date_value = $this->getSafeDate($start_date_wild); $end_date_value = $this->getSafeDate($end_date_wild); $viewer = $this->requireViewer(); $timezone = new DateTimeZone($viewer->getTimezoneIdentifier()); $min_range = null; $max_range = null; $min_range = $start_date_value->getEpoch(); $max_range = $end_date_value->getEpoch(); if ($display == 'month' || $display == 'day') { list($start_year, $start_month, $start_day) = $this->getDisplayYearAndMonthAndDay($min_range, $max_range, $display); $start_day = new DateTime( "{$start_year}-{$start_month}-{$start_day}", $timezone); $next = clone $start_day; if ($display == 'month') { $next->modify('+1 month'); } else if ($display == 'day') { $next->modify('+7 day'); } $display_start = $start_day->format('U'); $display_end = $next->format('U'); $start_of_week = $viewer->getUserSetting( PhabricatorWeekStartDaySetting::SETTINGKEY); $end_of_week = ($start_of_week + 6) % 7; $first_of_month = $start_day->format('w'); $last_of_month = id(clone $next)->modify('-1 day')->format('w'); if (!$min_range || ($min_range < $display_start)) { $min_range = $display_start; if ($display == 'month' && $first_of_month !== $start_of_week) { $interim_day_num = ($first_of_month + 7 - $start_of_week) % 7; $min_range = id(clone $start_day) ->modify('-'.$interim_day_num.' days') ->format('U'); } } if (!$max_range || ($max_range > $display_end)) { $max_range = $display_end; if ($display == 'month' && $last_of_month !== $end_of_week) { $interim_day_num = ($end_of_week + 7 - $last_of_month) % 7; $max_range = id(clone $next) ->modify('+'.$interim_day_num.' days') ->format('U'); } } } if ($upcoming) { + $now = PhabricatorTime::getNow(); if ($min_range) { - $min_range = max(time(), $min_range); + $min_range = max($now, $min_range); } else { - $min_range = time(); + $min_range = $now; } } return array($min_range, $max_range); } protected function getURI($path) { return '/calendar/'.$path; } protected function getBuiltinQueryNames() { $names = array( 'month' => pht('Month View'), 'day' => pht('Day View'), 'upcoming' => pht('Upcoming Events'), 'all' => pht('All Events'), ); return $names; } public function setCalendarYearAndMonthAndDay($year, $month, $day = null) { $this->calendarYear = $year; $this->calendarMonth = $month; $this->calendarDay = $day; return $this; } public function buildSavedQueryFromBuiltin($query_key) { $query = $this->newSavedQuery(); $query->setQueryKey($query_key); switch ($query_key) { case 'month': return $query->setParameter('display', 'month'); case 'day': return $query->setParameter('display', 'day'); case 'upcoming': return $query ->setParameter('display', 'list') ->setParameter('upcoming', array( 0 => 'upcoming', )); case 'all': return $query; } return parent::buildSavedQueryFromBuiltin($query_key); } protected function getRequiredHandlePHIDsForResultList( array $objects, PhabricatorSavedQuery $query) { $phids = array(); foreach ($objects as $event) { $phids[$event->getUserPHID()] = 1; } return array_keys($phids); } protected function renderResultList( array $events, PhabricatorSavedQuery $query, array $handles) { if ($this->isMonthView($query)) { return $this->buildCalendarView($events, $query, $handles); } else if ($this->isDayView($query)) { return $this->buildCalendarDayView($events, $query, $handles); } assert_instances_of($events, 'PhabricatorCalendarEvent'); $viewer = $this->requireViewer(); $list = new PHUIObjectItemListView(); foreach ($events as $event) { $event_date_info = $this->getEventDateLabel($event); $creator_handle = $handles[$event->getUserPHID()]; $attendees = array(); foreach ($event->getInvitees() as $invitee) { $status_attending = PhabricatorCalendarEventInvitee::STATUS_ATTENDING; if ($invitee->getStatus() === $status_attending) { $attendees[] = $invitee->getInviteePHID(); } } if ($event->getIsGhostEvent()) { $title_text = $event->getMonogram() .' (' .$event->getSequenceIndex() .'): ' .$event->getName(); } else { $title_text = $event->getMonogram().': '.$event->getName(); } $item = id(new PHUIObjectItemView()) ->setUser($viewer) ->setObject($event) ->setHeader($title_text) ->setHref($event->getURI()) ->addAttribute($event_date_info); if ($attendees) { $attending = pht( 'Attending: %s', $viewer->renderHandleList($attendees) ->setAsInline(1) ->render()); $item->addAttribute($attending); } if ($event->getDuration()) { $duration = pht( 'Duration: %s', $event->getDisplayDuration()); $item->addIcon('none', $duration); } $list->addItem($item); } $result = new PhabricatorApplicationSearchResultView(); $result->setObjectList($list); $result->setNoDataString(pht('No events found.')); return $result; } private function buildCalendarView( array $statuses, PhabricatorSavedQuery $query, array $handles) { $viewer = $this->requireViewer(); $now = time(); list($start_year, $start_month) = $this->getDisplayYearAndMonthAndDay( $this->getQueryDateFrom($query)->getEpoch(), $this->getQueryDateTo($query)->getEpoch(), $query->getParameter('display')); $now_year = phabricator_format_local_time($now, $viewer, 'Y'); $now_month = phabricator_format_local_time($now, $viewer, 'm'); $now_day = phabricator_format_local_time($now, $viewer, 'j'); if ($start_month == $now_month && $start_year == $now_year) { $month_view = new PHUICalendarMonthView( $this->getQueryDateFrom($query), $this->getQueryDateTo($query), $start_month, $start_year, $now_day); } else { $month_view = new PHUICalendarMonthView( $this->getQueryDateFrom($query), $this->getQueryDateTo($query), $start_month, $start_year); } $month_view->setUser($viewer); $phids = mpull($statuses, 'getUserPHID'); foreach ($statuses as $status) { $viewer_is_invited = $status->getIsUserInvited($viewer->getPHID()); $event = new AphrontCalendarEventView(); $event->setEpochRange( $status->getViewerDateFrom(), $status->getViewerDateTo()); $event->setIsAllDay($status->getIsAllDay()); $event->setIcon($status->getIcon()); $name_text = $handles[$status->getUserPHID()]->getName(); $status_text = $status->getName(); $event->setUserPHID($status->getUserPHID()); $event->setDescription(pht('%s (%s)', $name_text, $status_text)); $event->setName($status_text); $event->setURI($status->getURI()); $event->setViewerIsInvited($viewer_is_invited); $month_view->addEvent($event); } $month_view->setBrowseURI( $this->getURI('query/'.$query->getQueryKey().'/')); // TODO redesign-2015 : Move buttons out of PHUICalendarView? $result = new PhabricatorApplicationSearchResultView(); $result->setContent($month_view); return $result; } private function buildCalendarDayView( array $statuses, PhabricatorSavedQuery $query, array $handles) { $viewer = $this->requireViewer(); list($start_year, $start_month, $start_day) = $this->getDisplayYearAndMonthAndDay( $this->getQueryDateFrom($query)->getEpoch(), $this->getQueryDateTo($query)->getEpoch(), $query->getParameter('display')); $day_view = id(new PHUICalendarDayView( $this->getQueryDateFrom($query)->getEpoch(), $this->getQueryDateTo($query)->getEpoch(), $start_year, $start_month, $start_day)) ->setQuery($query->getQueryKey()); $day_view->setUser($viewer); $phids = mpull($statuses, 'getUserPHID'); foreach ($statuses as $status) { if ($status->getIsCancelled()) { continue; } $viewer_is_invited = $status->getIsUserInvited($viewer->getPHID()); $can_edit = PhabricatorPolicyFilter::hasCapability( $viewer, $status, PhabricatorPolicyCapability::CAN_EDIT); $event = new AphrontCalendarEventView(); $event->setCanEdit($can_edit); $event->setEventID($status->getID()); $event->setEpochRange( $status->getViewerDateFrom(), $status->getViewerDateTo()); $event->setIsAllDay($status->getIsAllDay()); $event->setIcon($status->getIcon()); $event->setViewerIsInvited($viewer_is_invited); $event->setName($status->getName()); $event->setURI($status->getURI()); $day_view->addEvent($event); } $day_view->setBrowseURI( $this->getURI('query/'.$query->getQueryKey().'/')); $result = new PhabricatorApplicationSearchResultView(); $result->setContent($day_view); return $result; } private function getDisplayYearAndMonthAndDay( $range_start, $range_end, $display) { $viewer = $this->requireViewer(); $epoch = null; if ($this->calendarYear && $this->calendarMonth) { $start_year = $this->calendarYear; $start_month = $this->calendarMonth; $start_day = $this->calendarDay ? $this->calendarDay : 1; } else { if ($range_start) { $epoch = $range_start; } else if ($range_end) { $epoch = $range_end; } else { $epoch = time(); } if ($display == 'month') { $day = 1; } else { $day = phabricator_format_local_time($epoch, $viewer, 'd'); } $start_year = phabricator_format_local_time($epoch, $viewer, 'Y'); $start_month = phabricator_format_local_time($epoch, $viewer, 'm'); $start_day = $day; } return array($start_year, $start_month, $start_day); } public function getPageSize(PhabricatorSavedQuery $saved) { if ($this->isMonthView($saved) || $this->isDayView($saved)) { return $saved->getParameter('limit', 1000); } else { return $saved->getParameter('limit', 100); } } private function getQueryDateFrom(PhabricatorSavedQuery $saved) { return $this->getQueryDate($saved, 'rangeStart'); } private function getQueryDateTo(PhabricatorSavedQuery $saved) { return $this->getQueryDate($saved, 'rangeEnd'); } private function getQueryDate(PhabricatorSavedQuery $saved, $key) { $viewer = $this->requireViewer(); $wild = $saved->getParameter($key); return $this->getSafeDate($wild); } private function getSafeDate($value) { $viewer = $this->requireViewer(); if ($value) { // ideally this would be consistent and always pass in the same type if ($value instanceof AphrontFormDateControlValue) { return $value; } else { $value = AphrontFormDateControlValue::newFromWild($viewer, $value); } } else { $value = AphrontFormDateControlValue::newFromEpoch( $viewer, PhabricatorTime::getTodayMidnightDateTime($viewer)->format('U')); $value->setEnabled(false); } $value->setOptional(true); return $value; } private function isMonthView(PhabricatorSavedQuery $query) { if ($this->isDayView($query)) { return false; } if ($query->getParameter('display') == 'month') { return true; } } private function isDayView(PhabricatorSavedQuery $query) { if ($query->getParameter('display') == 'day') { return true; } if ($this->calendarDay) { return true; } return false; } private function getEventDateLabel($event) { $viewer = $this->requireViewer(); $from_datetime = PhabricatorTime::getDateTimeFromEpoch( $event->getViewerDateFrom(), $viewer); $to_datetime = PhabricatorTime::getDateTimeFromEpoch( $event->getViewerDateTo(), $viewer); $from_date_formatted = $from_datetime->format('Y m d'); $to_date_formatted = $to_datetime->format('Y m d'); if ($event->getIsAllDay()) { if ($from_date_formatted == $to_date_formatted) { return pht( '%s, All Day', phabricator_date($event->getViewerDateFrom(), $viewer)); } else { return pht( '%s - %s, All Day', phabricator_date($event->getViewerDateFrom(), $viewer), phabricator_date($event->getViewerDateTo(), $viewer)); } } else if ($from_date_formatted == $to_date_formatted) { return pht( '%s - %s', phabricator_datetime($event->getViewerDateFrom(), $viewer), phabricator_time($event->getViewerDateTo(), $viewer)); } else { return pht( '%s - %s', phabricator_datetime($event->getViewerDateFrom(), $viewer), phabricator_datetime($event->getViewerDateTo(), $viewer)); } } } diff --git a/src/infrastructure/query/policy/PhabricatorCursorPagedPolicyAwareQuery.php b/src/infrastructure/query/policy/PhabricatorCursorPagedPolicyAwareQuery.php index 0efca33b6a..8d43980122 100644 --- a/src/infrastructure/query/policy/PhabricatorCursorPagedPolicyAwareQuery.php +++ b/src/infrastructure/query/policy/PhabricatorCursorPagedPolicyAwareQuery.php @@ -1,2106 +1,2113 @@ getResultCursor(head($page)), $this->getResultCursor(last($page)), ); } protected function getResultCursor($object) { if (!is_object($object)) { throw new Exception( pht( 'Expected object, got "%s".', gettype($object))); } return $object->getID(); } protected function nextPage(array $page) { // See getPagingViewer() for a description of this flag. $this->internalPaging = true; if ($this->beforeID !== null) { $page = array_reverse($page, $preserve_keys = true); list($before, $after) = $this->getPageCursors($page); $this->beforeID = $before; } else { list($before, $after) = $this->getPageCursors($page); $this->afterID = $after; } } final public function setAfterID($object_id) { $this->afterID = $object_id; return $this; } final protected function getAfterID() { return $this->afterID; } final public function setBeforeID($object_id) { $this->beforeID = $object_id; return $this; } final protected function getBeforeID() { return $this->beforeID; } protected function loadStandardPage(PhabricatorLiskDAO $table) { $rows = $this->loadStandardPageRows($table); return $table->loadAllFromArray($rows); } protected function loadStandardPageRows(PhabricatorLiskDAO $table) { $conn = $table->establishConnection('r'); $rows = queryfx_all( $conn, '%Q FROM %T %Q %Q %Q %Q %Q %Q %Q', $this->buildSelectClause($conn), $table->getTableName(), (string)$this->getPrimaryTableAlias(), $this->buildJoinClause($conn), $this->buildWhereClause($conn), $this->buildGroupClause($conn), $this->buildHavingClause($conn), $this->buildOrderClause($conn), $this->buildLimitClause($conn)); return $rows; } /** * Get the viewer for making cursor paging queries. * * NOTE: You should ONLY use this viewer to load cursor objects while * building paging queries. * * Cursor paging can happen in two ways. First, the user can request a page * like `/stuff/?after=33`, which explicitly causes paging. Otherwise, we * can fall back to implicit paging if we filter some results out of a * result list because the user can't see them and need to go fetch some more * results to generate a large enough result list. * * In the first case, want to use the viewer's policies to load the object. * This prevents an attacker from figuring out information about an object * they can't see by executing queries like `/stuff/?after=33&order=name`, * which would otherwise give them a hint about the name of the object. * Generally, if a user can't see an object, they can't use it to page. * * In the second case, we need to load the object whether the user can see * it or not, because we need to examine new results. For example, if a user * loads `/stuff/` and we run a query for the first 100 items that they can * see, but the first 100 rows in the database aren't visible, we need to * be able to issue a query for the next 100 results. If we can't load the * cursor object, we'll fail or issue the same query over and over again. * So, generally, internal paging must bypass policy controls. * * This method returns the appropriate viewer, based on the context in which * the paging is occuring. * * @return PhabricatorUser Viewer for executing paging queries. */ final protected function getPagingViewer() { if ($this->internalPaging) { return PhabricatorUser::getOmnipotentUser(); } else { return $this->getViewer(); } } final protected function buildLimitClause(AphrontDatabaseConnection $conn_r) { - if ($this->getRawResultLimit()) { - return qsprintf($conn_r, 'LIMIT %d', $this->getRawResultLimit()); - } else { - return ''; + if ($this->shouldLimitResults()) { + $limit = $this->getRawResultLimit(); + if ($limit) { + return qsprintf($conn_r, 'LIMIT %d', $limit); + } } + + return ''; + } + + protected function shouldLimitResults() { + return true; } final protected function didLoadResults(array $results) { if ($this->beforeID) { $results = array_reverse($results, $preserve_keys = true); } return $results; } final public function executeWithCursorPager(AphrontCursorPagerView $pager) { $limit = $pager->getPageSize(); $this->setLimit($limit + 1); if ($pager->getAfterID()) { $this->setAfterID($pager->getAfterID()); } else if ($pager->getBeforeID()) { $this->setBeforeID($pager->getBeforeID()); } $results = $this->execute(); $count = count($results); $sliced_results = $pager->sliceResults($results); if ($sliced_results) { list($before, $after) = $this->getPageCursors($sliced_results); if ($pager->getBeforeID() || ($count > $limit)) { $pager->setNextPageID($after); } if ($pager->getAfterID() || ($pager->getBeforeID() && ($count > $limit))) { $pager->setPrevPageID($before); } } return $sliced_results; } /** * Return the alias this query uses to identify the primary table. * * Some automatic query constructions may need to be qualified with a table * alias if the query performs joins which make column names ambiguous. If * this is the case, return the alias for the primary table the query * uses; generally the object table which has `id` and `phid` columns. * * @return string Alias for the primary table. */ protected function getPrimaryTableAlias() { return null; } public function newResultObject() { return null; } /* -( Building Query Clauses )--------------------------------------------- */ /** * @task clauses */ protected function buildSelectClause(AphrontDatabaseConnection $conn) { $parts = $this->buildSelectClauseParts($conn); return $this->formatSelectClause($parts); } /** * @task clauses */ protected function buildSelectClauseParts(AphrontDatabaseConnection $conn) { $select = array(); $alias = $this->getPrimaryTableAlias(); if ($alias) { $select[] = qsprintf($conn, '%T.*', $alias); } else { $select[] = '*'; } $select[] = $this->buildEdgeLogicSelectClause($conn); return $select; } /** * @task clauses */ protected function buildJoinClause(AphrontDatabaseConnection $conn) { $joins = $this->buildJoinClauseParts($conn); return $this->formatJoinClause($joins); } /** * @task clauses */ protected function buildJoinClauseParts(AphrontDatabaseConnection $conn) { $joins = array(); $joins[] = $this->buildEdgeLogicJoinClause($conn); $joins[] = $this->buildApplicationSearchJoinClause($conn); $joins[] = $this->buildNgramsJoinClause($conn); return $joins; } /** * @task clauses */ protected function buildWhereClause(AphrontDatabaseConnection $conn) { $where = $this->buildWhereClauseParts($conn); return $this->formatWhereClause($where); } /** * @task clauses */ protected function buildWhereClauseParts(AphrontDatabaseConnection $conn) { $where = array(); $where[] = $this->buildPagingClause($conn); $where[] = $this->buildEdgeLogicWhereClause($conn); $where[] = $this->buildSpacesWhereClause($conn); $where[] = $this->buildNgramsWhereClause($conn); return $where; } /** * @task clauses */ protected function buildHavingClause(AphrontDatabaseConnection $conn) { $having = $this->buildHavingClauseParts($conn); return $this->formatHavingClause($having); } /** * @task clauses */ protected function buildHavingClauseParts(AphrontDatabaseConnection $conn) { $having = array(); $having[] = $this->buildEdgeLogicHavingClause($conn); return $having; } /** * @task clauses */ protected function buildGroupClause(AphrontDatabaseConnection $conn) { if (!$this->shouldGroupQueryResultRows()) { return ''; } return qsprintf( $conn, 'GROUP BY %Q', $this->getApplicationSearchObjectPHIDColumn()); } /** * @task clauses */ protected function shouldGroupQueryResultRows() { if ($this->shouldGroupEdgeLogicResultRows()) { return true; } if ($this->getApplicationSearchMayJoinMultipleRows()) { return true; } if ($this->shouldGroupNgramResultRows()) { return true; } return false; } /* -( Paging )------------------------------------------------------------- */ /** * @task paging */ protected function buildPagingClause(AphrontDatabaseConnection $conn) { $orderable = $this->getOrderableColumns(); $vector = $this->getOrderVector(); if ($this->beforeID !== null) { $cursor = $this->beforeID; $reversed = true; } else if ($this->afterID !== null) { $cursor = $this->afterID; $reversed = false; } else { // No paging is being applied to this query so we do not need to // construct a paging clause. return ''; } $keys = array(); foreach ($vector as $order) { $keys[] = $order->getOrderKey(); } $value_map = $this->getPagingValueMap($cursor, $keys); $columns = array(); foreach ($vector as $order) { $key = $order->getOrderKey(); if (!array_key_exists($key, $value_map)) { throw new Exception( pht( 'Query "%s" failed to return a value from getPagingValueMap() '. 'for column "%s".', get_class($this), $key)); } $column = $orderable[$key]; $column['value'] = $value_map[$key]; // If the vector component is reversed, we need to reverse whatever the // order of the column is. if ($order->getIsReversed()) { $column['reverse'] = !idx($column, 'reverse', false); } $columns[] = $column; } return $this->buildPagingClauseFromMultipleColumns( $conn, $columns, array( 'reversed' => $reversed, )); } /** * @task paging */ protected function getPagingValueMap($cursor, array $keys) { return array( 'id' => $cursor, ); } /** * @task paging */ protected function loadCursorObject($cursor) { $query = newv(get_class($this), array()) ->setViewer($this->getPagingViewer()) ->withIDs(array((int)$cursor)); $this->willExecuteCursorQuery($query); $object = $query->executeOne(); if (!$object) { throw new Exception( pht( 'Cursor "%s" does not identify a valid object in query "%s".', $cursor, get_class($this))); } return $object; } /** * @task paging */ protected function willExecuteCursorQuery( PhabricatorCursorPagedPolicyAwareQuery $query) { return; } /** * Simplifies the task of constructing a paging clause across multiple * columns. In the general case, this looks like: * * A > a OR (A = a AND B > b) OR (A = a AND B = b AND C > c) * * To build a clause, specify the name, type, and value of each column * to include: * * $this->buildPagingClauseFromMultipleColumns( * $conn_r, * array( * array( * 'table' => 't', * 'column' => 'title', * 'type' => 'string', * 'value' => $cursor->getTitle(), * 'reverse' => true, * ), * array( * 'table' => 't', * 'column' => 'id', * 'type' => 'int', * 'value' => $cursor->getID(), * ), * ), * array( * 'reversed' => $is_reversed, * )); * * This method will then return a composable clause for inclusion in WHERE. * * @param AphrontDatabaseConnection Connection query will execute on. * @param list Column description dictionaries. * @param map Additional constuction options. * @return string Query clause. * @task paging */ final protected function buildPagingClauseFromMultipleColumns( AphrontDatabaseConnection $conn, array $columns, array $options) { foreach ($columns as $column) { PhutilTypeSpec::checkMap( $column, array( 'table' => 'optional string|null', 'column' => 'string', 'value' => 'wild', 'type' => 'string', 'reverse' => 'optional bool', 'unique' => 'optional bool', 'null' => 'optional string|null', )); } PhutilTypeSpec::checkMap( $options, array( 'reversed' => 'optional bool', )); $is_query_reversed = idx($options, 'reversed', false); $clauses = array(); $accumulated = array(); $last_key = last_key($columns); foreach ($columns as $key => $column) { $type = $column['type']; $null = idx($column, 'null'); if ($column['value'] === null) { if ($null) { $value = null; } else { throw new Exception( pht( 'Column "%s" has null value, but does not specify a null '. 'behavior.', $key)); } } else { switch ($type) { case 'int': $value = qsprintf($conn, '%d', $column['value']); break; case 'float': $value = qsprintf($conn, '%f', $column['value']); break; case 'string': $value = qsprintf($conn, '%s', $column['value']); break; default: throw new Exception( pht( 'Column "%s" has unknown column type "%s".', $column['column'], $type)); } } $is_column_reversed = idx($column, 'reverse', false); $reverse = ($is_query_reversed xor $is_column_reversed); $clause = $accumulated; $table_name = idx($column, 'table'); $column_name = $column['column']; if ($table_name !== null) { $field = qsprintf($conn, '%T.%T', $table_name, $column_name); } else { $field = qsprintf($conn, '%T', $column_name); } $parts = array(); if ($null) { $can_page_if_null = ($null === 'head'); $can_page_if_nonnull = ($null === 'tail'); if ($reverse) { $can_page_if_null = !$can_page_if_null; $can_page_if_nonnull = !$can_page_if_nonnull; } $subclause = null; if ($can_page_if_null && $value === null) { $parts[] = qsprintf( $conn, '(%Q IS NOT NULL)', $field); } else if ($can_page_if_nonnull && $value !== null) { $parts[] = qsprintf( $conn, '(%Q IS NULL)', $field); } } if ($value !== null) { $parts[] = qsprintf( $conn, '%Q %Q %Q', $field, $reverse ? '>' : '<', $value); } if ($parts) { if (count($parts) > 1) { $clause[] = '('.implode(') OR (', $parts).')'; } else { $clause[] = head($parts); } } if ($clause) { if (count($clause) > 1) { $clauses[] = '('.implode(') AND (', $clause).')'; } else { $clauses[] = head($clause); } } if ($value === null) { $accumulated[] = qsprintf( $conn, '%Q IS NULL', $field); } else { $accumulated[] = qsprintf( $conn, '%Q = %Q', $field, $value); } } return '('.implode(') OR (', $clauses).')'; } /* -( Result Ordering )---------------------------------------------------- */ /** * Select a result ordering. * * This is a high-level method which selects an ordering from a predefined * list of builtin orders, as provided by @{method:getBuiltinOrders}. These * options are user-facing and not exhaustive, but are generally convenient * and meaningful. * * You can also use @{method:setOrderVector} to specify a low-level ordering * across individual orderable columns. This offers greater control but is * also more involved. * * @param string Key of a builtin order supported by this query. * @return this * @task order */ public function setOrder($order) { $aliases = $this->getBuiltinOrderAliasMap(); if (empty($aliases[$order])) { throw new Exception( pht( 'Query "%s" does not support a builtin order "%s". Supported orders '. 'are: %s.', get_class($this), $order, implode(', ', array_keys($aliases)))); } $this->builtinOrder = $aliases[$order]; $this->orderVector = null; return $this; } /** * Set a grouping order to apply before primary result ordering. * * This allows you to preface the query order vector with additional orders, * so you can effect "group by" queries while still respecting "order by". * * This is a high-level method which works alongside @{method:setOrder}. For * lower-level control over order vectors, use @{method:setOrderVector}. * * @param PhabricatorQueryOrderVector|list List of order keys. * @return this * @task order */ public function setGroupVector($vector) { $this->groupVector = $vector; $this->orderVector = null; return $this; } /** * Get builtin orders for this class. * * In application UIs, we want to be able to present users with a small * selection of meaningful order options (like "Order by Title") rather than * an exhaustive set of column ordering options. * * Meaningful user-facing orders are often really orders across multiple * columns: for example, a "title" ordering is usually implemented as a * "title, id" ordering under the hood. * * Builtin orders provide a mapping from convenient, understandable * user-facing orders to implementations. * * A builtin order should provide these keys: * * - `vector` (`list`): The actual order vector to use. * - `name` (`string`): Human-readable order name. * * @return map Map from builtin order keys to specification. * @task order */ public function getBuiltinOrders() { $orders = array( 'newest' => array( 'vector' => array('id'), 'name' => pht('Creation (Newest First)'), 'aliases' => array('created'), ), 'oldest' => array( 'vector' => array('-id'), 'name' => pht('Creation (Oldest First)'), ), ); $object = $this->newResultObject(); if ($object instanceof PhabricatorCustomFieldInterface) { $list = PhabricatorCustomField::getObjectFields( $object, PhabricatorCustomField::ROLE_APPLICATIONSEARCH); foreach ($list->getFields() as $field) { $index = $field->buildOrderIndex(); if (!$index) { continue; } $legacy_key = 'custom:'.$field->getFieldKey(); $modern_key = $field->getModernFieldKey(); $orders[$modern_key] = array( 'vector' => array($modern_key, 'id'), 'name' => $field->getFieldName(), 'aliases' => array($legacy_key), ); $orders['-'.$modern_key] = array( 'vector' => array('-'.$modern_key, '-id'), 'name' => pht('%s (Reversed)', $field->getFieldName()), ); } } return $orders; } public function getBuiltinOrderAliasMap() { $orders = $this->getBuiltinOrders(); $map = array(); foreach ($orders as $key => $order) { $keys = array(); $keys[] = $key; foreach (idx($order, 'aliases', array()) as $alias) { $keys[] = $alias; } foreach ($keys as $alias) { if (isset($map[$alias])) { throw new Exception( pht( 'Two builtin orders ("%s" and "%s") define the same key or '. 'alias ("%s"). Each order alias and key must be unique and '. 'identify a single order.', $key, $map[$alias], $alias)); } $map[$alias] = $key; } } return $map; } /** * Set a low-level column ordering. * * This is a low-level method which offers granular control over column * ordering. In most cases, applications can more easily use * @{method:setOrder} to choose a high-level builtin order. * * To set an order vector, specify a list of order keys as provided by * @{method:getOrderableColumns}. * * @param PhabricatorQueryOrderVector|list List of order keys. * @return this * @task order */ public function setOrderVector($vector) { $vector = PhabricatorQueryOrderVector::newFromVector($vector); $orderable = $this->getOrderableColumns(); // Make sure that all the components identify valid columns. $unique = array(); foreach ($vector as $order) { $key = $order->getOrderKey(); if (empty($orderable[$key])) { $valid = implode(', ', array_keys($orderable)); throw new Exception( pht( 'This query ("%s") does not support sorting by order key "%s". '. 'Supported orders are: %s.', get_class($this), $key, $valid)); } $unique[$key] = idx($orderable[$key], 'unique', false); } // Make sure that the last column is unique so that this is a strong // ordering which can be used for paging. $last = last($unique); if ($last !== true) { throw new Exception( pht( 'Order vector "%s" is invalid: the last column in an order must '. 'be a column with unique values, but "%s" is not unique.', $vector->getAsString(), last_key($unique))); } // Make sure that other columns are not unique; an ordering like "id, name" // does not make sense because only "id" can ever have an effect. array_pop($unique); foreach ($unique as $key => $is_unique) { if ($is_unique) { throw new Exception( pht( 'Order vector "%s" is invalid: only the last column in an order '. 'may be unique, but "%s" is a unique column and not the last '. 'column in the order.', $vector->getAsString(), $key)); } } $this->orderVector = $vector; return $this; } /** * Get the effective order vector. * * @return PhabricatorQueryOrderVector Effective vector. * @task order */ protected function getOrderVector() { if (!$this->orderVector) { if ($this->builtinOrder !== null) { $builtin_order = idx($this->getBuiltinOrders(), $this->builtinOrder); $vector = $builtin_order['vector']; } else { $vector = $this->getDefaultOrderVector(); } if ($this->groupVector) { $group = PhabricatorQueryOrderVector::newFromVector($this->groupVector); $group->appendVector($vector); $vector = $group; } $vector = PhabricatorQueryOrderVector::newFromVector($vector); // We call setOrderVector() here to apply checks to the default vector. // This catches any errors in the implementation. $this->setOrderVector($vector); } return $this->orderVector; } /** * @task order */ protected function getDefaultOrderVector() { return array('id'); } /** * @task order */ public function getOrderableColumns() { $cache = PhabricatorCaches::getRequestCache(); $class = get_class($this); $cache_key = 'query.orderablecolumns.'.$class; $columns = $cache->getKey($cache_key); if ($columns !== null) { return $columns; } $columns = array( 'id' => array( 'table' => $this->getPrimaryTableAlias(), 'column' => 'id', 'reverse' => false, 'type' => 'int', 'unique' => true, ), ); $object = $this->newResultObject(); if ($object instanceof PhabricatorCustomFieldInterface) { $list = PhabricatorCustomField::getObjectFields( $object, PhabricatorCustomField::ROLE_APPLICATIONSEARCH); foreach ($list->getFields() as $field) { $index = $field->buildOrderIndex(); if (!$index) { continue; } $digest = $field->getFieldIndex(); $key = $field->getModernFieldKey(); $columns[$key] = array( 'table' => 'appsearch_order_'.$digest, 'column' => 'indexValue', 'type' => $index->getIndexValueType(), 'null' => 'tail', 'customfield' => true, 'customfield.index.table' => $index->getTableName(), 'customfield.index.key' => $digest, ); } } $cache->setKey($cache_key, $columns); return $columns; } /** * @task order */ final protected function buildOrderClause(AphrontDatabaseConnection $conn) { $orderable = $this->getOrderableColumns(); $vector = $this->getOrderVector(); $parts = array(); foreach ($vector as $order) { $part = $orderable[$order->getOrderKey()]; if ($order->getIsReversed()) { $part['reverse'] = !idx($part, 'reverse', false); } $parts[] = $part; } return $this->formatOrderClause($conn, $parts); } /** * @task order */ protected function formatOrderClause( AphrontDatabaseConnection $conn, array $parts) { $is_query_reversed = false; if ($this->getBeforeID()) { $is_query_reversed = !$is_query_reversed; } $sql = array(); foreach ($parts as $key => $part) { $is_column_reversed = !empty($part['reverse']); $descending = true; if ($is_query_reversed) { $descending = !$descending; } if ($is_column_reversed) { $descending = !$descending; } $table = idx($part, 'table'); $column = $part['column']; if ($table !== null) { $field = qsprintf($conn, '%T.%T', $table, $column); } else { $field = qsprintf($conn, '%T', $column); } $null = idx($part, 'null'); if ($null) { switch ($null) { case 'head': $null_field = qsprintf($conn, '(%Q IS NULL)', $field); break; case 'tail': $null_field = qsprintf($conn, '(%Q IS NOT NULL)', $field); break; default: throw new Exception( pht( 'NULL value "%s" is invalid. Valid values are "head" and '. '"tail".', $null)); } if ($descending) { $sql[] = qsprintf($conn, '%Q DESC', $null_field); } else { $sql[] = qsprintf($conn, '%Q ASC', $null_field); } } if ($descending) { $sql[] = qsprintf($conn, '%Q DESC', $field); } else { $sql[] = qsprintf($conn, '%Q ASC', $field); } } return qsprintf($conn, 'ORDER BY %Q', implode(', ', $sql)); } /* -( Application Search )------------------------------------------------- */ /** * Constrain the query with an ApplicationSearch index, requiring field values * contain at least one of the values in a set. * * This constraint can build the most common types of queries, like: * * - Find users with shirt sizes "X" or "XL". * - Find shoes with size "13". * * @param PhabricatorCustomFieldIndexStorage Table where the index is stored. * @param string|list One or more values to filter by. * @return this * @task appsearch */ public function withApplicationSearchContainsConstraint( PhabricatorCustomFieldIndexStorage $index, $value) { $this->applicationSearchConstraints[] = array( 'type' => $index->getIndexValueType(), 'cond' => '=', 'table' => $index->getTableName(), 'index' => $index->getIndexKey(), 'value' => $value, ); return $this; } /** * Constrain the query with an ApplicationSearch index, requiring values * exist in a given range. * * This constraint is useful for expressing date ranges: * * - Find events between July 1st and July 7th. * * The ends of the range are inclusive, so a `$min` of `3` and a `$max` of * `5` will match fields with values `3`, `4`, or `5`. Providing `null` for * either end of the range will leave that end of the constraint open. * * @param PhabricatorCustomFieldIndexStorage Table where the index is stored. * @param int|null Minimum permissible value, inclusive. * @param int|null Maximum permissible value, inclusive. * @return this * @task appsearch */ public function withApplicationSearchRangeConstraint( PhabricatorCustomFieldIndexStorage $index, $min, $max) { $index_type = $index->getIndexValueType(); if ($index_type != 'int') { throw new Exception( pht( 'Attempting to apply a range constraint to a field with index type '. '"%s", expected type "%s".', $index_type, 'int')); } $this->applicationSearchConstraints[] = array( 'type' => $index->getIndexValueType(), 'cond' => 'range', 'table' => $index->getTableName(), 'index' => $index->getIndexKey(), 'value' => array($min, $max), ); return $this; } /** * Get the name of the query's primary object PHID column, for constructing * JOIN clauses. Normally (and by default) this is just `"phid"`, but it may * be something more exotic. * * See @{method:getPrimaryTableAlias} if the column needs to be qualified with * a table alias. * * @return string Column name. * @task appsearch */ protected function getApplicationSearchObjectPHIDColumn() { if ($this->getPrimaryTableAlias()) { $prefix = $this->getPrimaryTableAlias().'.'; } else { $prefix = ''; } return $prefix.'phid'; } /** * Determine if the JOINs built by ApplicationSearch might cause each primary * object to return multiple result rows. Generally, this means the query * needs an extra GROUP BY clause. * * @return bool True if the query may return multiple rows for each object. * @task appsearch */ protected function getApplicationSearchMayJoinMultipleRows() { foreach ($this->applicationSearchConstraints as $constraint) { $type = $constraint['type']; $value = $constraint['value']; $cond = $constraint['cond']; switch ($cond) { case '=': switch ($type) { case 'string': case 'int': if (count((array)$value) > 1) { return true; } break; default: throw new Exception(pht('Unknown index type "%s"!', $type)); } break; case 'range': // NOTE: It's possible to write a custom field where multiple rows // match a range constraint, but we don't currently ship any in the // upstream and I can't immediately come up with cases where this // would make sense. break; default: throw new Exception(pht('Unknown constraint condition "%s"!', $cond)); } } return false; } /** * Construct a GROUP BY clause appropriate for ApplicationSearch constraints. * * @param AphrontDatabaseConnection Connection executing the query. * @return string Group clause. * @task appsearch */ protected function buildApplicationSearchGroupClause( AphrontDatabaseConnection $conn_r) { if ($this->getApplicationSearchMayJoinMultipleRows()) { return qsprintf( $conn_r, 'GROUP BY %Q', $this->getApplicationSearchObjectPHIDColumn()); } else { return ''; } } /** * Construct a JOIN clause appropriate for applying ApplicationSearch * constraints. * * @param AphrontDatabaseConnection Connection executing the query. * @return string Join clause. * @task appsearch */ protected function buildApplicationSearchJoinClause( AphrontDatabaseConnection $conn_r) { $joins = array(); foreach ($this->applicationSearchConstraints as $key => $constraint) { $table = $constraint['table']; $alias = 'appsearch_'.$key; $index = $constraint['index']; $cond = $constraint['cond']; $phid_column = $this->getApplicationSearchObjectPHIDColumn(); switch ($cond) { case '=': $type = $constraint['type']; switch ($type) { case 'string': $constraint_clause = qsprintf( $conn_r, '%T.indexValue IN (%Ls)', $alias, (array)$constraint['value']); break; case 'int': $constraint_clause = qsprintf( $conn_r, '%T.indexValue IN (%Ld)', $alias, (array)$constraint['value']); break; default: throw new Exception(pht('Unknown index type "%s"!', $type)); } $joins[] = qsprintf( $conn_r, 'JOIN %T %T ON %T.objectPHID = %Q AND %T.indexKey = %s AND (%Q)', $table, $alias, $alias, $phid_column, $alias, $index, $constraint_clause); break; case 'range': list($min, $max) = $constraint['value']; if (($min === null) && ($max === null)) { // If there's no actual range constraint, just move on. break; } if ($min === null) { $constraint_clause = qsprintf( $conn_r, '%T.indexValue <= %d', $alias, $max); } else if ($max === null) { $constraint_clause = qsprintf( $conn_r, '%T.indexValue >= %d', $alias, $min); } else { $constraint_clause = qsprintf( $conn_r, '%T.indexValue BETWEEN %d AND %d', $alias, $min, $max); } $joins[] = qsprintf( $conn_r, 'JOIN %T %T ON %T.objectPHID = %Q AND %T.indexKey = %s AND (%Q)', $table, $alias, $alias, $phid_column, $alias, $index, $constraint_clause); break; default: throw new Exception(pht('Unknown constraint condition "%s"!', $cond)); } } $phid_column = $this->getApplicationSearchObjectPHIDColumn(); $orderable = $this->getOrderableColumns(); $vector = $this->getOrderVector(); foreach ($vector as $order) { $spec = $orderable[$order->getOrderKey()]; if (empty($spec['customfield'])) { continue; } $table = $spec['customfield.index.table']; $alias = $spec['table']; $key = $spec['customfield.index.key']; $joins[] = qsprintf( $conn_r, 'LEFT JOIN %T %T ON %T.objectPHID = %Q AND %T.indexKey = %s', $table, $alias, $alias, $phid_column, $alias, $key); } return implode(' ', $joins); } /* -( Integration with CustomField )--------------------------------------- */ /** * @task customfield */ protected function getPagingValueMapForCustomFields( PhabricatorCustomFieldInterface $object) { // We have to get the current field values on the cursor object. $fields = PhabricatorCustomField::getObjectFields( $object, PhabricatorCustomField::ROLE_APPLICATIONSEARCH); $fields->setViewer($this->getViewer()); $fields->readFieldsFromStorage($object); $map = array(); foreach ($fields->getFields() as $field) { $map['custom:'.$field->getFieldKey()] = $field->getValueForStorage(); } return $map; } /** * @task customfield */ protected function isCustomFieldOrderKey($key) { $prefix = 'custom:'; return !strncmp($key, $prefix, strlen($prefix)); } /* -( Ngrams )------------------------------------------------------------- */ protected function withNgramsConstraint( PhabricatorSearchNgrams $index, $value) { if (strlen($value)) { $this->ngrams[] = array( 'index' => $index, 'value' => $value, 'length' => count(phutil_utf8v($value)), ); } return $this; } protected function buildNgramsJoinClause(AphrontDatabaseConnection $conn) { $flat = array(); foreach ($this->ngrams as $spec) { $index = $spec['index']; $value = $spec['value']; $length = $spec['length']; if ($length >= 3) { $ngrams = $index->getNgramsFromString($value, 'query'); $prefix = false; } else if ($length == 2) { $ngrams = $index->getNgramsFromString($value, 'prefix'); $prefix = false; } else { $ngrams = array(' '.$value); $prefix = true; } foreach ($ngrams as $ngram) { $flat[] = array( 'table' => $index->getTableName(), 'ngram' => $ngram, 'prefix' => $prefix, ); } } // MySQL only allows us to join a maximum of 61 tables per query. Each // ngram is going to cost us a join toward that limit, so if the user // specified a very long query string, just pick 16 of the ngrams // at random. if (count($flat) > 16) { shuffle($flat); $flat = array_slice($flat, 0, 16); } $alias = $this->getPrimaryTableAlias(); if ($alias) { $id_column = qsprintf($conn, '%T.%T', $alias, 'id'); } else { $id_column = qsprintf($conn, '%T', 'id'); } $idx = 1; $joins = array(); foreach ($flat as $spec) { $table = $spec['table']; $ngram = $spec['ngram']; $prefix = $spec['prefix']; $alias = 'ngm'.$idx++; if ($prefix) { $joins[] = qsprintf( $conn, 'JOIN %T %T ON %T.objectID = %Q AND %T.ngram LIKE %>', $table, $alias, $alias, $id_column, $alias, $ngram); } else { $joins[] = qsprintf( $conn, 'JOIN %T %T ON %T.objectID = %Q AND %T.ngram = %s', $table, $alias, $alias, $id_column, $alias, $ngram); } } return $joins; } protected function buildNgramsWhereClause(AphrontDatabaseConnection $conn) { $where = array(); foreach ($this->ngrams as $ngram) { $index = $ngram['index']; $value = $ngram['value']; $column = $index->getColumnName(); $alias = $this->getPrimaryTableAlias(); if ($alias) { $column = qsprintf($conn, '%T.%T', $alias, $column); } else { $column = qsprintf($conn, '%T', $column); } $tokens = $index->tokenizeString($value); foreach ($tokens as $token) { $where[] = qsprintf( $conn, '%Q LIKE %~', $column, $token); } } return $where; } protected function shouldGroupNgramResultRows() { return (bool)$this->ngrams; } /* -( Edge Logic )--------------------------------------------------------- */ /** * Convenience method for specifying edge logic constraints with a list of * PHIDs. * * @param const Edge constant. * @param const Constraint operator. * @param list List of PHIDs. * @return this * @task edgelogic */ public function withEdgeLogicPHIDs($edge_type, $operator, array $phids) { $constraints = array(); foreach ($phids as $phid) { $constraints[] = new PhabricatorQueryConstraint($operator, $phid); } return $this->withEdgeLogicConstraints($edge_type, $constraints); } /** * @task edgelogic */ public function withEdgeLogicConstraints($edge_type, array $constraints) { assert_instances_of($constraints, 'PhabricatorQueryConstraint'); $constraints = mgroup($constraints, 'getOperator'); foreach ($constraints as $operator => $list) { foreach ($list as $item) { $this->edgeLogicConstraints[$edge_type][$operator][] = $item; } } $this->edgeLogicConstraintsAreValid = false; return $this; } /** * @task edgelogic */ public function buildEdgeLogicSelectClause(AphrontDatabaseConnection $conn) { $select = array(); $this->validateEdgeLogicConstraints(); foreach ($this->edgeLogicConstraints as $type => $constraints) { foreach ($constraints as $operator => $list) { $alias = $this->getEdgeLogicTableAlias($operator, $type); switch ($operator) { case PhabricatorQueryConstraint::OPERATOR_AND: if (count($list) > 1) { $select[] = qsprintf( $conn, 'COUNT(DISTINCT(%T.dst)) %T', $alias, $this->buildEdgeLogicTableAliasCount($alias)); } break; case PhabricatorQueryConstraint::OPERATOR_ANCESTOR: // This is tricky. We have a query which specifies multiple // projects, each of which may have an arbitrarily large number // of descendants. // Suppose the projects are "Engineering" and "Operations", and // "Engineering" has subprojects X, Y and Z. // We first use `FIELD(dst, X, Y, Z)` to produce a 0 if a row // is not part of Engineering at all, or some number other than // 0 if it is. // Then we use `IF(..., idx, NULL)` to convert the 0 to a NULL and // any other value to an index (say, 1) for the ancestor. // We build these up for every ancestor, then use `COALESCE(...)` // to select the non-null one, giving us an ancestor which this // row is a member of. // From there, we use `COUNT(DISTINCT(...))` to make sure that // each result row is a member of all ancestors. if (count($list) > 1) { $idx = 1; $parts = array(); foreach ($list as $constraint) { $parts[] = qsprintf( $conn, 'IF(FIELD(%T.dst, %Ls) != 0, %d, NULL)', $alias, (array)$constraint->getValue(), $idx++); } $parts = implode(', ', $parts); $select[] = qsprintf( $conn, 'COUNT(DISTINCT(COALESCE(%Q))) %T', $parts, $this->buildEdgeLogicTableAliasAncestor($alias)); } break; default: break; } } } return $select; } /** * @task edgelogic */ public function buildEdgeLogicJoinClause(AphrontDatabaseConnection $conn) { $edge_table = PhabricatorEdgeConfig::TABLE_NAME_EDGE; $phid_column = $this->getApplicationSearchObjectPHIDColumn(); $joins = array(); foreach ($this->edgeLogicConstraints as $type => $constraints) { $op_null = PhabricatorQueryConstraint::OPERATOR_NULL; $has_null = isset($constraints[$op_null]); foreach ($constraints as $operator => $list) { $alias = $this->getEdgeLogicTableAlias($operator, $type); $phids = array(); foreach ($list as $constraint) { $value = (array)$constraint->getValue(); foreach ($value as $v) { $phids[$v] = $v; } } $phids = array_keys($phids); switch ($operator) { case PhabricatorQueryConstraint::OPERATOR_NOT: $joins[] = qsprintf( $conn, 'LEFT JOIN %T %T ON %Q = %T.src AND %T.type = %d AND %T.dst IN (%Ls)', $edge_table, $alias, $phid_column, $alias, $alias, $type, $alias, $phids); break; case PhabricatorQueryConstraint::OPERATOR_ANCESTOR: case PhabricatorQueryConstraint::OPERATOR_AND: case PhabricatorQueryConstraint::OPERATOR_OR: // If we're including results with no matches, we have to degrade // this to a LEFT join. We'll use WHERE to select matching rows // later. if ($has_null) { $join_type = 'LEFT'; } else { $join_type = ''; } $joins[] = qsprintf( $conn, '%Q JOIN %T %T ON %Q = %T.src AND %T.type = %d AND %T.dst IN (%Ls)', $join_type, $edge_table, $alias, $phid_column, $alias, $alias, $type, $alias, $phids); break; case PhabricatorQueryConstraint::OPERATOR_NULL: $joins[] = qsprintf( $conn, 'LEFT JOIN %T %T ON %Q = %T.src AND %T.type = %d', $edge_table, $alias, $phid_column, $alias, $alias, $type); break; } } } return $joins; } /** * @task edgelogic */ public function buildEdgeLogicWhereClause(AphrontDatabaseConnection $conn) { $where = array(); foreach ($this->edgeLogicConstraints as $type => $constraints) { $full = array(); $null = array(); $op_null = PhabricatorQueryConstraint::OPERATOR_NULL; $has_null = isset($constraints[$op_null]); foreach ($constraints as $operator => $list) { $alias = $this->getEdgeLogicTableAlias($operator, $type); switch ($operator) { case PhabricatorQueryConstraint::OPERATOR_NOT: $full[] = qsprintf( $conn, '%T.dst IS NULL', $alias); break; case PhabricatorQueryConstraint::OPERATOR_AND: case PhabricatorQueryConstraint::OPERATOR_OR: if ($has_null) { $full[] = qsprintf( $conn, '%T.dst IS NOT NULL', $alias); } break; case PhabricatorQueryConstraint::OPERATOR_NULL: $null[] = qsprintf( $conn, '%T.dst IS NULL', $alias); break; } } if ($full && $null) { $full = $this->formatWhereSubclause($full); $null = $this->formatWhereSubclause($null); $where[] = qsprintf($conn, '(%Q OR %Q)', $full, $null); } else if ($full) { foreach ($full as $condition) { $where[] = $condition; } } else if ($null) { foreach ($null as $condition) { $where[] = $condition; } } } return $where; } /** * @task edgelogic */ public function buildEdgeLogicHavingClause(AphrontDatabaseConnection $conn) { $having = array(); foreach ($this->edgeLogicConstraints as $type => $constraints) { foreach ($constraints as $operator => $list) { $alias = $this->getEdgeLogicTableAlias($operator, $type); switch ($operator) { case PhabricatorQueryConstraint::OPERATOR_AND: if (count($list) > 1) { $having[] = qsprintf( $conn, '%T = %d', $this->buildEdgeLogicTableAliasCount($alias), count($list)); } break; case PhabricatorQueryConstraint::OPERATOR_ANCESTOR: if (count($list) > 1) { $having[] = qsprintf( $conn, '%T = %d', $this->buildEdgeLogicTableAliasAncestor($alias), count($list)); } break; } } } return $having; } /** * @task edgelogic */ public function shouldGroupEdgeLogicResultRows() { foreach ($this->edgeLogicConstraints as $type => $constraints) { foreach ($constraints as $operator => $list) { switch ($operator) { case PhabricatorQueryConstraint::OPERATOR_NOT: case PhabricatorQueryConstraint::OPERATOR_AND: case PhabricatorQueryConstraint::OPERATOR_OR: case PhabricatorQueryConstraint::OPERATOR_ANCESTOR: if (count($list) > 1) { return true; } break; case PhabricatorQueryConstraint::OPERATOR_NULL: return true; } } } return false; } /** * @task edgelogic */ private function getEdgeLogicTableAlias($operator, $type) { return 'edgelogic_'.$operator.'_'.$type; } /** * @task edgelogic */ private function buildEdgeLogicTableAliasCount($alias) { return $alias.'_count'; } /** * @task edgelogic */ private function buildEdgeLogicTableAliasAncestor($alias) { return $alias.'_ancestor'; } /** * Select certain edge logic constraint values. * * @task edgelogic */ protected function getEdgeLogicValues( array $edge_types, array $operators) { $values = array(); $constraint_lists = $this->edgeLogicConstraints; if ($edge_types) { $constraint_lists = array_select_keys($constraint_lists, $edge_types); } foreach ($constraint_lists as $type => $constraints) { if ($operators) { $constraints = array_select_keys($constraints, $operators); } foreach ($constraints as $operator => $list) { foreach ($list as $constraint) { $value = (array)$constraint->getValue(); foreach ($value as $v) { $values[] = $v; } } } } return $values; } /** * Validate edge logic constraints for the query. * * @return this * @task edgelogic */ private function validateEdgeLogicConstraints() { if ($this->edgeLogicConstraintsAreValid) { return $this; } foreach ($this->edgeLogicConstraints as $type => $constraints) { foreach ($constraints as $operator => $list) { switch ($operator) { case PhabricatorQueryConstraint::OPERATOR_EMPTY: throw new PhabricatorEmptyQueryException( pht('This query specifies an empty constraint.')); } } } // This should probably be more modular, eventually, but we only do // project-based edge logic today. $project_phids = $this->getEdgeLogicValues( array( PhabricatorProjectObjectHasProjectEdgeType::EDGECONST, ), array( PhabricatorQueryConstraint::OPERATOR_AND, PhabricatorQueryConstraint::OPERATOR_OR, PhabricatorQueryConstraint::OPERATOR_NOT, PhabricatorQueryConstraint::OPERATOR_ANCESTOR, )); if ($project_phids) { $projects = id(new PhabricatorProjectQuery()) ->setViewer($this->getViewer()) ->setParentQuery($this) ->withPHIDs($project_phids) ->execute(); $projects = mpull($projects, null, 'getPHID'); foreach ($project_phids as $phid) { if (empty($projects[$phid])) { throw new PhabricatorEmptyQueryException( pht( 'This query is constrained by a project you do not have '. 'permission to see.')); } } } $this->edgeLogicConstraintsAreValid = true; return $this; } /* -( Spaces )------------------------------------------------------------- */ /** * Constrain the query to return results from only specific Spaces. * * Pass a list of Space PHIDs, or `null` to represent the default space. Only * results in those Spaces will be returned. * * Queries are always constrained to include only results from spaces the * viewer has access to. * * @param list * @task spaces */ public function withSpacePHIDs(array $space_phids) { $object = $this->newResultObject(); if (!$object) { throw new Exception( pht( 'This query (of class "%s") does not implement newResultObject(), '. 'but must implement this method to enable support for Spaces.', get_class($this))); } if (!($object instanceof PhabricatorSpacesInterface)) { throw new Exception( pht( 'This query (of class "%s") returned an object of class "%s" from '. 'getNewResultObject(), but it does not implement the required '. 'interface ("%s"). Objects must implement this interface to enable '. 'Spaces support.', get_class($this), get_class($object), 'PhabricatorSpacesInterface')); } $this->spacePHIDs = $space_phids; return $this; } public function withSpaceIsArchived($archived) { $this->spaceIsArchived = $archived; return $this; } /** * Constrain the query to include only results in valid Spaces. * * This method builds part of a WHERE clause which considers the spaces the * viewer has access to see with any explicit constraint on spaces added by * @{method:withSpacePHIDs}. * * @param AphrontDatabaseConnection Database connection. * @return string Part of a WHERE clause. * @task spaces */ private function buildSpacesWhereClause(AphrontDatabaseConnection $conn) { $object = $this->newResultObject(); if (!$object) { return null; } if (!($object instanceof PhabricatorSpacesInterface)) { return null; } $viewer = $this->getViewer(); // If we have an omnipotent viewer and no formal space constraints, don't // emit a clause. This primarily enables older migrations to run cleanly, // without fataling because they try to match a `spacePHID` column which // does not exist yet. See T8743, T8746. if ($viewer->isOmnipotent()) { if ($this->spaceIsArchived === null && $this->spacePHIDs === null) { return null; } } $space_phids = array(); $include_null = false; $all = PhabricatorSpacesNamespaceQuery::getAllSpaces(); if (!$all) { // If there are no spaces at all, implicitly give the viewer access to // the default space. $include_null = true; } else { // Otherwise, give them access to the spaces they have permission to // see. $viewer_spaces = PhabricatorSpacesNamespaceQuery::getViewerSpaces( $viewer); foreach ($viewer_spaces as $viewer_space) { if ($this->spaceIsArchived !== null) { if ($viewer_space->getIsArchived() != $this->spaceIsArchived) { continue; } } $phid = $viewer_space->getPHID(); $space_phids[$phid] = $phid; if ($viewer_space->getIsDefaultNamespace()) { $include_null = true; } } } // If we have additional explicit constraints, evaluate them now. if ($this->spacePHIDs !== null) { $explicit = array(); $explicit_null = false; foreach ($this->spacePHIDs as $phid) { if ($phid === null) { $space = PhabricatorSpacesNamespaceQuery::getDefaultSpace(); } else { $space = idx($all, $phid); } if ($space) { $phid = $space->getPHID(); $explicit[$phid] = $phid; if ($space->getIsDefaultNamespace()) { $explicit_null = true; } } } // If the viewer can see the default space but it isn't on the explicit // list of spaces to query, don't match it. if ($include_null && !$explicit_null) { $include_null = false; } // Include only the spaces common to the viewer and the constraints. $space_phids = array_intersect_key($space_phids, $explicit); } if (!$space_phids && !$include_null) { if ($this->spacePHIDs === null) { throw new PhabricatorEmptyQueryException( pht('You do not have access to any spaces.')); } else { throw new PhabricatorEmptyQueryException( pht( 'You do not have access to any of the spaces this query '. 'is constrained to.')); } } $alias = $this->getPrimaryTableAlias(); if ($alias) { $col = qsprintf($conn, '%T.spacePHID', $alias); } else { $col = 'spacePHID'; } if ($space_phids && $include_null) { return qsprintf( $conn, '(%Q IN (%Ls) OR %Q IS NULL)', $col, $space_phids, $col); } else if ($space_phids) { return qsprintf( $conn, '%Q IN (%Ls)', $col, $space_phids); } else { return qsprintf( $conn, '%Q IS NULL', $col); } } }