diff --git a/resources/sql/autopatches/20140706.pedge.1.sql b/resources/sql/autopatches/20140706.pedge.1.sql
new file mode 100644
index 0000000000..3dd049105c
--- /dev/null
+++ b/resources/sql/autopatches/20140706.pedge.1.sql
@@ -0,0 +1,10 @@
+/* PhabricatorProjectObjectHasProjectEdgeType::EDGECONST = 41 */
+/* PhabricatorProjectProjectHasObjectEdgeType::EDGECONST = 42 */
+
+INSERT IGNORE INTO {$NAMESPACE}_maniphest.edge (src, type, dst)
+  SELECT taskPHID, 41, projectPHID
+  FROM {$NAMESPACE}_maniphest.maniphest_taskproject;
+
+INSERT IGNORE INTO {$NAMESPACE}_project.edge (src, type, dst)
+  SELECT projectPHID, 42, taskPHID
+  FROM {$NAMESPACE}_maniphest.maniphest_taskproject;
diff --git a/src/applications/maniphest/controller/ManiphestReportController.php b/src/applications/maniphest/controller/ManiphestReportController.php
index 00b00d81b5..6fd69d432c 100644
--- a/src/applications/maniphest/controller/ManiphestReportController.php
+++ b/src/applications/maniphest/controller/ManiphestReportController.php
@@ -1,766 +1,767 @@
 <?php
 
 final class ManiphestReportController extends ManiphestController {
 
   private $view;
 
   public function willProcessRequest(array $data) {
     $this->view = idx($data, 'view');
   }
 
   public function processRequest() {
     $request = $this->getRequest();
     $user = $request->getUser();
 
     if ($request->isFormPost()) {
       $uri = $request->getRequestURI();
 
       $project = head($request->getArr('set_project'));
       $project = nonempty($project, null);
       $uri = $uri->alter('project', $project);
 
       $window = $request->getStr('set_window');
       $uri = $uri->alter('window', $window);
 
       return id(new AphrontRedirectResponse())->setURI($uri);
     }
 
     $nav = new AphrontSideNavFilterView();
     $nav->setBaseURI(new PhutilURI('/maniphest/report/'));
     $nav->addLabel(pht('Open Tasks'));
     $nav->addFilter('user', pht('By User'));
     $nav->addFilter('project', pht('By Project'));
     $nav->addLabel(pht('Burnup'));
     $nav->addFilter('burn', pht('Burnup Rate'));
 
     $this->view = $nav->selectFilter($this->view, 'user');
 
     require_celerity_resource('maniphest-report-css');
 
     switch ($this->view) {
       case 'burn':
         $core = $this->renderBurn();
         break;
       case 'user':
       case 'project':
         $core = $this->renderOpenTasks();
         break;
       default:
         return new Aphront404Response();
     }
 
     $nav->appendChild($core);
     $nav->setCrumbs(
       $this->buildApplicationCrumbs()
         ->addTextCrumb(pht('Reports')));
 
     return $this->buildApplicationPage(
       $nav,
       array(
         'title' => pht('Maniphest Reports'),
         'device' => false,
       ));
   }
 
   public function renderBurn() {
     $request = $this->getRequest();
     $user = $request->getUser();
 
     $handle = null;
 
     $project_phid = $request->getStr('project');
     if ($project_phid) {
       $phids = array($project_phid);
       $handles = $this->loadViewerHandles($phids);
       $handle = $handles[$project_phid];
     }
 
     $table = new ManiphestTransaction();
     $conn = $table->establishConnection('r');
 
     $joins = '';
     if ($project_phid) {
       $joins = qsprintf(
         $conn,
         'JOIN %T t ON x.objectPHID = t.phid
-          JOIN %T p ON p.taskPHID = t.phid AND p.projectPHID = %s',
+          JOIN %T p ON p.src = t.phid AND p.type = %d AND p.dst = %s',
         id(new ManiphestTask())->getTableName(),
-        id(new ManiphestTaskProject())->getTableName(),
+        PhabricatorEdgeConfig::TABLE_NAME_EDGE,
+        PhabricatorProjectObjectHasProjectEdgeType::EDGECONST,
         $project_phid);
     }
 
     $data = queryfx_all(
       $conn,
       'SELECT x.oldValue, x.newValue, x.dateCreated FROM %T x %Q
         WHERE transactionType = %s
         ORDER BY x.dateCreated ASC',
       $table->getTableName(),
       $joins,
       ManiphestTransaction::TYPE_STATUS);
 
     $stats = array();
     $day_buckets = array();
 
     $open_tasks = array();
 
     foreach ($data as $key => $row) {
 
       // NOTE: Hack to avoid json_decode().
       $oldv = trim($row['oldValue'], '"');
       $newv = trim($row['newValue'], '"');
 
       if ($oldv == 'null') {
         $old_is_open = false;
       } else {
         $old_is_open = ManiphestTaskStatus::isOpenStatus($oldv);
       }
 
       $new_is_open = ManiphestTaskStatus::isOpenStatus($newv);
 
       $is_open  = ($new_is_open && !$old_is_open);
       $is_close = ($old_is_open && !$new_is_open);
 
       $data[$key]['_is_open'] = $is_open;
       $data[$key]['_is_close'] = $is_close;
 
       if (!$is_open && !$is_close) {
         // This is either some kind of bogus event, or a resolution change
         // (e.g., resolved -> invalid). Just skip it.
         continue;
       }
 
       $day_bucket = phabricator_format_local_time(
         $row['dateCreated'],
         $user,
         'Yz');
       $day_buckets[$day_bucket] = $row['dateCreated'];
       if (empty($stats[$day_bucket])) {
         $stats[$day_bucket] = array(
           'open'  => 0,
           'close' => 0,
         );
       }
       $stats[$day_bucket][$is_close ? 'close' : 'open']++;
     }
 
     $template = array(
       'open'  => 0,
       'close' => 0,
     );
 
     $rows = array();
     $rowc = array();
     $last_month = null;
     $last_month_epoch = null;
     $last_week = null;
     $last_week_epoch = null;
     $week = null;
     $month = null;
 
     $last = last_key($stats) - 1;
     $period = $template;
 
     foreach ($stats as $bucket => $info) {
       $epoch = $day_buckets[$bucket];
 
       $week_bucket = phabricator_format_local_time(
         $epoch,
         $user,
         'YW');
       if ($week_bucket != $last_week) {
         if ($week) {
           $rows[] = $this->formatBurnRow(
             'Week of '.phabricator_date($last_week_epoch, $user),
             $week);
           $rowc[] = 'week';
         }
         $week = $template;
         $last_week = $week_bucket;
         $last_week_epoch = $epoch;
       }
 
       $month_bucket = phabricator_format_local_time(
         $epoch,
         $user,
         'Ym');
       if ($month_bucket != $last_month) {
         if ($month) {
           $rows[] = $this->formatBurnRow(
             phabricator_format_local_time($last_month_epoch, $user, 'F, Y'),
             $month);
           $rowc[] = 'month';
         }
         $month = $template;
         $last_month = $month_bucket;
         $last_month_epoch = $epoch;
       }
 
       $rows[] = $this->formatBurnRow(phabricator_date($epoch, $user), $info);
       $rowc[] = null;
       $week['open'] += $info['open'];
       $week['close'] += $info['close'];
       $month['open'] += $info['open'];
       $month['close'] += $info['close'];
       $period['open'] += $info['open'];
       $period['close'] += $info['close'];
     }
 
     if ($week) {
       $rows[] = $this->formatBurnRow(
         pht('Week To Date'),
         $week);
       $rowc[] = 'week';
     }
 
     if ($month) {
       $rows[] = $this->formatBurnRow(
         pht('Month To Date'),
         $month);
       $rowc[] = 'month';
     }
 
     $rows[] = $this->formatBurnRow(
       pht('All Time'),
       $period);
     $rowc[] = 'aggregate';
 
     $rows = array_reverse($rows);
     $rowc = array_reverse($rowc);
 
     $table = new AphrontTableView($rows);
     $table->setRowClasses($rowc);
     $table->setHeaders(
       array(
         pht('Period'),
         pht('Opened'),
         pht('Closed'),
         pht('Change'),
       ));
     $table->setColumnClasses(
       array(
         'right wide',
         'n',
         'n',
         'n',
       ));
 
     if ($handle) {
       $inst = pht(
         'NOTE: This table reflects tasks currently in '.
         'the project. If a task was opened in the past but added to '.
         'the project recently, it is counted on the day it was '.
         'opened, not the day it was categorized. If a task was part '.
         'of this project in the past but no longer is, it is not '.
         'counted at all.');
       $header = pht('Task Burn Rate for Project %s', $handle->renderLink());
       $caption = phutil_tag('p', array(), $inst);
     } else {
       $header = pht('Task Burn Rate for All Tasks');
       $caption = null;
     }
 
     if ($caption) {
       $caption = id(new AphrontErrorView())
         ->appendChild($caption)
         ->setSeverity(AphrontErrorView::SEVERITY_NOTICE);
     }
 
     $panel = new PHUIObjectBoxView();
     $panel->setHeaderText($header);
     if ($caption) {
       $panel->setErrorView($caption);
     }
     $panel->appendChild($table);
 
     $tokens = array();
     if ($handle) {
       $tokens = array($handle);
     }
 
     $filter = $this->renderReportFilters($tokens, $has_window = false);
 
     $id = celerity_generate_unique_node_id();
     $chart = phutil_tag(
       'div',
       array(
         'id' => $id,
         'style' => 'border: 1px solid #BFCFDA; '.
                    'background-color: #fff; '.
                    'margin: 8px 16px; '.
                    'height: 400px; ',
       ),
       '');
 
     list($burn_x, $burn_y) = $this->buildSeries($data);
 
     require_celerity_resource('raphael-core');
     require_celerity_resource('raphael-g');
     require_celerity_resource('raphael-g-line');
 
     Javelin::initBehavior('line-chart', array(
       'hardpoint' => $id,
       'x' => array(
         $burn_x,
       ),
       'y' => array(
         $burn_y,
       ),
       'xformat' => 'epoch',
       'yformat' => 'int',
     ));
 
     return array($filter, $chart, $panel);
   }
 
   private function renderReportFilters(array $tokens, $has_window) {
     $request = $this->getRequest();
     $user = $request->getUser();
 
     $form = id(new AphrontFormView())
       ->setUser($user)
       ->appendChild(
         id(new AphrontFormTokenizerControl())
           ->setDatasource('/typeahead/common/searchproject/')
           ->setLabel(pht('Project'))
           ->setLimit(1)
           ->setName('set_project')
           ->setValue($tokens));
 
     if ($has_window) {
       list($window_str, $ignored, $window_error) = $this->getWindow();
       $form
         ->appendChild(
           id(new AphrontFormTextControl())
             ->setLabel(pht('Recently Means'))
             ->setName('set_window')
             ->setCaption(
               pht('Configure the cutoff for the "Recently Closed" column.'))
             ->setValue($window_str)
             ->setError($window_error));
     }
 
     $form
       ->appendChild(
         id(new AphrontFormSubmitControl())
           ->setValue(pht('Filter By Project')));
 
     $filter = new AphrontListFilterView();
     $filter->appendChild($form);
 
     return $filter;
   }
 
   private function buildSeries(array $data) {
     $out = array();
 
     $counter = 0;
     foreach ($data as $row) {
       $t = (int)$row['dateCreated'];
       if ($row['_is_close']) {
         --$counter;
         $out[$t] = $counter;
       } else if ($row['_is_open']) {
         ++$counter;
         $out[$t] = $counter;
       }
     }
 
     return array(array_keys($out), array_values($out));
   }
 
   private function formatBurnRow($label, $info) {
     $delta = $info['open'] - $info['close'];
     $fmt = number_format($delta);
     if ($delta > 0) {
       $fmt = '+'.$fmt;
       $fmt = phutil_tag('span', array('class' => 'red'), $fmt);
     } else {
       $fmt = phutil_tag('span', array('class' => 'green'), $fmt);
     }
 
     return array(
       $label,
       number_format($info['open']),
       number_format($info['close']),
       $fmt);
   }
 
   public function renderOpenTasks() {
     $request = $this->getRequest();
     $user = $request->getUser();
 
 
     $query = id(new ManiphestTaskQuery())
       ->setViewer($user)
       ->withStatuses(ManiphestTaskStatus::getOpenStatusConstants());
 
     $project_phid = $request->getStr('project');
     $project_handle = null;
     if ($project_phid) {
       $phids = array($project_phid);
       $handles = $this->loadViewerHandles($phids);
       $project_handle = $handles[$project_phid];
 
       $query->withAnyProjects($phids);
     }
 
     $tasks = $query->execute();
 
     $recently_closed = $this->loadRecentlyClosedTasks();
 
     $date = phabricator_date(time(), $user);
 
     switch ($this->view) {
       case 'user':
         $result = mgroup($tasks, 'getOwnerPHID');
         $leftover = idx($result, '', array());
         unset($result['']);
 
         $result_closed = mgroup($recently_closed, 'getOwnerPHID');
         $leftover_closed = idx($result_closed, '', array());
         unset($result_closed['']);
 
         $base_link = '/maniphest/?assigned=';
         $leftover_name = phutil_tag('em', array(), pht('(Up For Grabs)'));
         $col_header = pht('User');
         $header = pht('Open Tasks by User and Priority (%s)', $date);
         break;
       case 'project':
         $result = array();
         $leftover = array();
         foreach ($tasks as $task) {
           $phids = $task->getProjectPHIDs();
           if ($phids) {
             foreach ($phids as $project_phid) {
               $result[$project_phid][] = $task;
             }
           } else {
             $leftover[] = $task;
           }
         }
 
         $result_closed = array();
         $leftover_closed = array();
         foreach ($recently_closed as $task) {
           $phids = $task->getProjectPHIDs();
           if ($phids) {
             foreach ($phids as $project_phid) {
               $result_closed[$project_phid][] = $task;
             }
           } else {
             $leftover_closed[] = $task;
           }
         }
 
         $base_link = '/maniphest/?allProjects=';
         $leftover_name = phutil_tag('em', array(), pht('(No Project)'));
         $col_header = pht('Project');
         $header = pht('Open Tasks by Project and Priority (%s)', $date);
         break;
     }
 
     $phids = array_keys($result);
     $handles = $this->loadViewerHandles($phids);
     $handles = msort($handles, 'getName');
 
     $order = $request->getStr('order', 'name');
     list($order, $reverse) = AphrontTableView::parseSort($order);
 
     require_celerity_resource('aphront-tooltip-css');
     Javelin::initBehavior('phabricator-tooltips', array());
 
     $rows = array();
     $pri_total = array();
     foreach (array_merge($handles, array(null)) as $handle) {
       if ($handle) {
         if (($project_handle) &&
             ($project_handle->getPHID() == $handle->getPHID())) {
           // If filtering by, e.g., "bugs", don't show a "bugs" group.
           continue;
         }
 
         $tasks = idx($result, $handle->getPHID(), array());
         $name = phutil_tag(
           'a',
           array(
             'href' => $base_link.$handle->getPHID(),
           ),
           $handle->getName());
         $closed = idx($result_closed, $handle->getPHID(), array());
       } else {
         $tasks = $leftover;
         $name  = $leftover_name;
         $closed = $leftover_closed;
       }
 
       $taskv = $tasks;
       $tasks = mgroup($tasks, 'getPriority');
 
       $row = array();
       $row[] = $name;
       $total = 0;
       foreach (ManiphestTaskPriority::getTaskPriorityMap() as $pri => $label) {
         $n = count(idx($tasks, $pri, array()));
         if ($n == 0) {
           $row[] = '-';
         } else {
           $row[] = number_format($n);
         }
         $total += $n;
       }
       $row[] = number_format($total);
 
       list($link, $oldest_all) = $this->renderOldest($taskv);
       $row[] = $link;
 
       $normal_or_better = array();
       foreach ($taskv as $id => $task) {
         // TODO: This is sort of a hard-code for the default "normal" status.
         // When reports are more powerful, this should be made more general.
         if ($task->getPriority() < 50) {
           continue;
         }
         $normal_or_better[$id] = $task;
       }
 
       list($link, $oldest_pri) = $this->renderOldest($normal_or_better);
       $row[] = $link;
 
       if ($closed) {
         $task_ids = implode(',', mpull($closed, 'getID'));
         $row[] = phutil_tag(
           'a',
           array(
             'href' => '/maniphest/?ids='.$task_ids,
             'target' => '_blank',
           ),
           number_format(count($closed)));
       } else {
         $row[] = '-';
       }
 
       switch ($order) {
         case 'total':
           $row['sort'] = $total;
           break;
         case 'oldest-all':
           $row['sort'] = $oldest_all;
           break;
         case 'oldest-pri':
           $row['sort'] = $oldest_pri;
           break;
         case 'closed':
           $row['sort'] = count($closed);
           break;
         case 'name':
         default:
           $row['sort'] = $handle ? $handle->getName() : '~';
           break;
       }
 
       $rows[] = $row;
     }
 
     $rows = isort($rows, 'sort');
     foreach ($rows as $k => $row) {
       unset($rows[$k]['sort']);
     }
     if ($reverse) {
       $rows = array_reverse($rows);
     }
 
     $cname = array($col_header);
     $cclass = array('pri right wide');
     $pri_map = ManiphestTaskPriority::getShortNameMap();
     foreach ($pri_map as $pri => $label) {
       $cname[] = $label;
       $cclass[] = 'n';
     }
     $cname[] = 'Total';
     $cclass[] = 'n';
     $cname[] = javelin_tag(
       'span',
       array(
         'sigil' => 'has-tooltip',
         'meta'  => array(
           'tip' => pht('Oldest open task.'),
           'size' => 200,
         ),
       ),
       pht('Oldest (All)'));
     $cclass[] = 'n';
     $cname[] = javelin_tag(
       'span',
       array(
         'sigil' => 'has-tooltip',
         'meta'  => array(
           'tip' => pht('Oldest open task, excluding those with Low or '.
                    'Wishlist priority.'),
           'size' => 200,
         ),
       ),
       pht('Oldest (Pri)'));
     $cclass[] = 'n';
 
     list($ignored, $window_epoch) = $this->getWindow();
     $edate = phabricator_datetime($window_epoch, $user);
     $cname[] = javelin_tag(
       'span',
       array(
         'sigil' => 'has-tooltip',
         'meta'  => array(
           'tip'  => pht('Closed after %s', $edate),
           'size' => 260
         ),
       ),
       pht('Recently Closed'));
     $cclass[] = 'n';
 
     $table = new AphrontTableView($rows);
     $table->setHeaders($cname);
     $table->setColumnClasses($cclass);
     $table->makeSortable(
       $request->getRequestURI(),
       'order',
       $order,
       $reverse,
       array(
         'name',
         null,
         null,
         null,
         null,
         null,
         null,
         'total',
         'oldest-all',
         'oldest-pri',
         'closed',
       ));
 
     $panel = new PHUIObjectBoxView();
     $panel->setHeaderText($header);
     $panel->appendChild($table);
 
     $tokens = array();
     if ($project_handle) {
       $tokens = array($project_handle);
     }
     $filter = $this->renderReportFilters($tokens, $has_window = true);
 
     return array($filter, $panel);
   }
 
 
   /**
    * Load all the tasks that have been recently closed.
    */
   private function loadRecentlyClosedTasks() {
     list($ignored, $window_epoch) = $this->getWindow();
 
     $table = new ManiphestTask();
     $xtable = new ManiphestTransaction();
     $conn_r = $table->establishConnection('r');
 
     // TODO: Gross. This table is not meant to be queried like this. Build
     // real stats tables.
 
     $open_status_list = array();
     foreach (ManiphestTaskStatus::getOpenStatusConstants() as $constant) {
       $open_status_list[] = json_encode((string)$constant);
     }
 
     $tasks = queryfx_all(
       $conn_r,
       'SELECT t.* FROM %T t JOIN %T x ON x.objectPHID = t.phid
         WHERE t.status NOT IN (%Ls)
         AND x.oldValue IN (null, %Ls)
         AND x.newValue NOT IN (%Ls)
         AND t.dateModified >= %d
         AND x.dateCreated >= %d',
       $table->getTableName(),
       $xtable->getTableName(),
       ManiphestTaskStatus::getOpenStatusConstants(),
       $open_status_list,
       $open_status_list,
       $window_epoch,
       $window_epoch);
 
     return id(new ManiphestTask())->loadAllFromArray($tasks);
   }
 
   /**
    * Parse the "Recently Means" filter into:
    *
    *    - A string representation, like "12 AM 7 days ago" (default);
    *    - a locale-aware epoch representation; and
    *    - a possible error.
    */
   private function getWindow() {
     $request = $this->getRequest();
     $user = $request->getUser();
 
     $window_str = $this->getRequest()->getStr('window', '12 AM 7 days ago');
 
     $error = null;
     $window_epoch = null;
 
     // Do locale-aware parsing so that the user's timezone is assumed for
     // time windows like "3 PM", rather than assuming the server timezone.
 
     $window_epoch = PhabricatorTime::parseLocalTime($window_str, $user);
     if (!$window_epoch) {
       $error = 'Invalid';
       $window_epoch = time() - (60 * 60 * 24 * 7);
     }
 
     // If the time ends up in the future, convert it to the corresponding time
     // and equal distance in the past. This is so users can type "6 days" (which
     // means "6 days from now") and get the behavior of "6 days ago", rather
     // than no results (because the window epoch is in the future). This might
     // be a little confusing because it casues "tomorrow" to mean "yesterday"
     // and "2022" (or whatever) to mean "ten years ago", but these inputs are
     // nonsense anyway.
 
     if ($window_epoch > time()) {
       $window_epoch = time() - ($window_epoch - time());
     }
 
     return array($window_str, $window_epoch, $error);
   }
 
   private function renderOldest(array $tasks) {
     assert_instances_of($tasks, 'ManiphestTask');
     $oldest = null;
     foreach ($tasks as $id => $task) {
       if (($oldest === null) ||
           ($task->getDateCreated() < $tasks[$oldest]->getDateCreated())) {
         $oldest = $id;
       }
     }
 
     if ($oldest === null) {
       return array('-', 0);
     }
 
     $oldest = $tasks[$oldest];
 
     $raw_age = (time() - $oldest->getDateCreated());
     $age = number_format($raw_age / (24 * 60 * 60)).' d';
 
     $link = javelin_tag(
       'a',
       array(
         'href'  => '/T'.$oldest->getID(),
         'sigil' => 'has-tooltip',
         'meta'  => array(
           'tip' => 'T'.$oldest->getID().': '.$oldest->getTitle(),
         ),
         'target' => '_blank',
       ),
       $age);
 
     return array($link, $raw_age);
   }
 
 }
diff --git a/src/applications/maniphest/query/ManiphestTaskQuery.php b/src/applications/maniphest/query/ManiphestTaskQuery.php
index 1b29705493..7e7616c0c8 100644
--- a/src/applications/maniphest/query/ManiphestTaskQuery.php
+++ b/src/applications/maniphest/query/ManiphestTaskQuery.php
@@ -1,942 +1,952 @@
 <?php
 
 /**
  * Query tasks by specific criteria. This class uses the higher-performance
  * but less-general Maniphest indexes to satisfy queries.
  */
 final class ManiphestTaskQuery extends PhabricatorCursorPagedPolicyAwareQuery {
 
   private $taskIDs             = array();
   private $taskPHIDs           = array();
   private $authorPHIDs         = array();
   private $ownerPHIDs          = array();
   private $includeUnowned      = null;
   private $projectPHIDs        = array();
   private $xprojectPHIDs       = array();
   private $subscriberPHIDs     = array();
   private $anyProjectPHIDs     = array();
   private $anyUserProjectPHIDs = array();
   private $includeNoProject    = null;
   private $dateCreatedAfter;
   private $dateCreatedBefore;
   private $dateModifiedAfter;
   private $dateModifiedBefore;
 
   private $fullTextSearch   = '';
 
   private $status           = 'status-any';
   const STATUS_ANY          = 'status-any';
   const STATUS_OPEN         = 'status-open';
   const STATUS_CLOSED       = 'status-closed';
   const STATUS_RESOLVED     = 'status-resolved';
   const STATUS_WONTFIX      = 'status-wontfix';
   const STATUS_INVALID      = 'status-invalid';
   const STATUS_SPITE        = 'status-spite';
   const STATUS_DUPLICATE    = 'status-duplicate';
 
   private $statuses;
   private $priorities;
 
   private $groupBy          = 'group-none';
   const GROUP_NONE          = 'group-none';
   const GROUP_PRIORITY      = 'group-priority';
   const GROUP_OWNER         = 'group-owner';
   const GROUP_STATUS        = 'group-status';
   const GROUP_PROJECT       = 'group-project';
 
   private $orderBy          = 'order-modified';
   const ORDER_PRIORITY      = 'order-priority';
   const ORDER_CREATED       = 'order-created';
   const ORDER_MODIFIED      = 'order-modified';
   const ORDER_TITLE         = 'order-title';
 
   const DEFAULT_PAGE_SIZE   = 1000;
 
   public function withAuthors(array $authors) {
     $this->authorPHIDs = $authors;
     return $this;
   }
 
   public function withIDs(array $ids) {
     $this->taskIDs = $ids;
     return $this;
   }
 
   public function withPHIDs(array $phids) {
     $this->taskPHIDs = $phids;
     return $this;
   }
 
   public function withOwners(array $owners) {
     $this->includeUnowned = false;
     foreach ($owners as $k => $phid) {
       if ($phid == ManiphestTaskOwner::OWNER_UP_FOR_GRABS || $phid === null) {
         $this->includeUnowned = true;
         unset($owners[$k]);
         break;
       }
     }
     $this->ownerPHIDs = $owners;
     return $this;
   }
 
   public function withAllProjects(array $projects) {
     $this->includeNoProject = false;
     foreach ($projects as $k => $phid) {
       if ($phid == ManiphestTaskOwner::PROJECT_NO_PROJECT) {
         $this->includeNoProject = true;
         unset($projects[$k]);
       }
     }
     $this->projectPHIDs = $projects;
     return $this;
   }
 
   /**
    * Add an additional "all projects" constraint to existing filters.
    *
    * This is used by boards to supplement queries.
    *
    * @param list<phid> List of project PHIDs to add to any existing constriant.
    * @return this
    */
   public function addWithAllProjects(array $projects) {
     if ($this->projectPHIDs === null) {
       $this->projectPHIDs = array();
     }
 
     return $this->withAllProjects(array_merge($this->projectPHIDs, $projects));
   }
 
   public function withoutProjects(array $projects) {
     $this->xprojectPHIDs = $projects;
     return $this;
   }
 
   public function withStatus($status) {
     $this->status = $status;
     return $this;
   }
 
   public function withStatuses(array $statuses) {
     $this->statuses = $statuses;
     return $this;
   }
 
   public function withPriorities(array $priorities) {
     $this->priorities = $priorities;
     return $this;
   }
 
   public function withSubscribers(array $subscribers) {
     $this->subscriberPHIDs = $subscribers;
     return $this;
   }
 
   public function withFullTextSearch($fulltext_search) {
     $this->fullTextSearch = $fulltext_search;
     return $this;
   }
 
   public function setGroupBy($group) {
     $this->groupBy = $group;
     return $this;
   }
 
   public function setOrderBy($order) {
     $this->orderBy = $order;
     return $this;
   }
 
   public function withAnyProjects(array $projects) {
     $this->anyProjectPHIDs = $projects;
     return $this;
   }
 
   public function withAnyUserProjects(array $users) {
     $this->anyUserProjectPHIDs = $users;
     return $this;
   }
 
   public function withDateCreatedBefore($date_created_before) {
     $this->dateCreatedBefore = $date_created_before;
     return $this;
   }
 
   public function withDateCreatedAfter($date_created_after) {
     $this->dateCreatedAfter = $date_created_after;
     return $this;
   }
 
   public function withDateModifiedBefore($date_modified_before) {
     $this->dateModifiedBefore = $date_modified_before;
     return $this;
   }
 
   public function withDateModifiedAfter($date_modified_after) {
     $this->dateModifiedAfter = $date_modified_after;
     return $this;
   }
 
   public function loadPage() {
     // TODO: (T603) It is possible for a user to find the PHID of a project
     // they can't see, then query for tasks in that project and deduce the
     // identity of unknown/invisible projects. Before we allow the user to
     // execute a project-based PHID query, we should verify that they
     // can see the project.
 
     $task_dao = new ManiphestTask();
     $conn = $task_dao->establishConnection('r');
 
     $where = array();
     $where[] = $this->buildTaskIDsWhereClause($conn);
     $where[] = $this->buildTaskPHIDsWhereClause($conn);
     $where[] = $this->buildStatusWhereClause($conn);
     $where[] = $this->buildStatusesWhereClause($conn);
     $where[] = $this->buildPrioritiesWhereClause($conn);
     $where[] = $this->buildAuthorWhereClause($conn);
     $where[] = $this->buildOwnerWhereClause($conn);
     $where[] = $this->buildSubscriberWhereClause($conn);
     $where[] = $this->buildProjectWhereClause($conn);
     $where[] = $this->buildAnyProjectWhereClause($conn);
     $where[] = $this->buildAnyUserProjectWhereClause($conn);
     $where[] = $this->buildXProjectWhereClause($conn);
     $where[] = $this->buildFullTextWhereClause($conn);
 
     if ($this->dateCreatedAfter) {
       $where[] = qsprintf(
         $conn,
         'dateCreated >= %d',
         $this->dateCreatedAfter);
     }
 
     if ($this->dateCreatedBefore) {
       $where[] = qsprintf(
         $conn,
         'dateCreated <= %d',
         $this->dateCreatedBefore);
     }
 
     if ($this->dateModifiedAfter) {
       $where[] = qsprintf(
         $conn,
         'dateModified >= %d',
         $this->dateModifiedAfter);
     }
 
     if ($this->dateModifiedBefore) {
       $where[] = qsprintf(
         $conn,
         'dateModified <= %d',
         $this->dateModifiedBefore);
     }
 
     $where[] = $this->buildPagingClause($conn);
 
     $where = $this->formatWhereClause($where);
 
     $having = '';
     $count = '';
 
     if (count($this->projectPHIDs) > 1) {
       // We want to treat the query as an intersection query, not a union
       // query. We sum the project count and require it be the same as the
       // number of projects we're searching for.
 
-      $count = ', COUNT(project.projectPHID) projectCount';
+      $count = ', COUNT(project.dst) projectCount';
       $having = qsprintf(
         $conn,
         'HAVING projectCount = %d',
         count($this->projectPHIDs));
     }
 
     $order = $this->buildCustomOrderClause($conn);
 
     // TODO: Clean up this nonstandardness.
     if (!$this->getLimit()) {
       $this->setLimit(self::DEFAULT_PAGE_SIZE);
     }
 
     $group_column = '';
     switch ($this->groupBy) {
       case self::GROUP_PROJECT:
         $group_column = qsprintf(
           $conn,
           ', projectGroupName.indexedObjectPHID projectGroupPHID');
         break;
     }
 
     $rows = queryfx_all(
       $conn,
       'SELECT task.* %Q %Q FROM %T task %Q %Q %Q %Q %Q %Q',
       $count,
       $group_column,
       $task_dao->getTableName(),
       $this->buildJoinsClause($conn),
       $where,
       $this->buildGroupClause($conn),
       $having,
       $order,
       $this->buildLimitClause($conn));
 
     switch ($this->groupBy) {
       case self::GROUP_PROJECT:
         $data = ipull($rows, null, 'id');
         break;
       default:
         $data = $rows;
         break;
     }
 
     $tasks = $task_dao->loadAllFromArray($data);
 
     switch ($this->groupBy) {
       case self::GROUP_PROJECT:
         $results = array();
         foreach ($rows as $row) {
           $task = clone $tasks[$row['id']];
           $task->attachGroupByProjectPHID($row['projectGroupPHID']);
           $results[] = $task;
         }
         $tasks = $results;
         break;
     }
 
     return $tasks;
   }
 
   protected function willFilterPage(array $tasks) {
     if ($this->groupBy == self::GROUP_PROJECT) {
       // We should only return project groups which the user can actually see.
       $project_phids = mpull($tasks, 'getGroupByProjectPHID');
       $projects = id(new PhabricatorProjectQuery())
         ->setViewer($this->getViewer())
         ->withPHIDs($project_phids)
         ->execute();
       $projects = mpull($projects, null, 'getPHID');
 
       foreach ($tasks as $key => $task) {
         if (!$task->getGroupByProjectPHID()) {
           // This task is either not in any projects, or only in projects
           // which we're ignoring because they're being queried for explicitly.
           continue;
         }
 
         if (empty($projects[$task->getGroupByProjectPHID()])) {
           unset($tasks[$key]);
         }
       }
     }
 
     return $tasks;
   }
 
   private function buildTaskIDsWhereClause(AphrontDatabaseConnection $conn) {
     if (!$this->taskIDs) {
       return null;
     }
 
     return qsprintf(
       $conn,
       'id in (%Ld)',
       $this->taskIDs);
   }
 
   private function buildTaskPHIDsWhereClause(AphrontDatabaseConnection $conn) {
     if (!$this->taskPHIDs) {
       return null;
     }
 
     return qsprintf(
       $conn,
       'phid in (%Ls)',
       $this->taskPHIDs);
   }
 
   private function buildStatusWhereClause(AphrontDatabaseConnection $conn) {
     static $map = array(
       self::STATUS_RESOLVED   => ManiphestTaskStatus::STATUS_CLOSED_RESOLVED,
       self::STATUS_WONTFIX    => ManiphestTaskStatus::STATUS_CLOSED_WONTFIX,
       self::STATUS_INVALID    => ManiphestTaskStatus::STATUS_CLOSED_INVALID,
       self::STATUS_SPITE      => ManiphestTaskStatus::STATUS_CLOSED_SPITE,
       self::STATUS_DUPLICATE  => ManiphestTaskStatus::STATUS_CLOSED_DUPLICATE,
     );
 
     switch ($this->status) {
       case self::STATUS_ANY:
         return null;
       case self::STATUS_OPEN:
         return qsprintf(
           $conn,
           'status IN (%Ls)',
           ManiphestTaskStatus::getOpenStatusConstants());
       case self::STATUS_CLOSED:
         return qsprintf(
           $conn,
           'status IN (%Ls)',
           ManiphestTaskStatus::getClosedStatusConstants());
       default:
         $constant = idx($map, $this->status);
         if (!$constant) {
           throw new Exception("Unknown status query '{$this->status}'!");
         }
         return qsprintf(
           $conn,
           'status = %s',
           $constant);
     }
   }
 
   private function buildStatusesWhereClause(AphrontDatabaseConnection $conn) {
     if ($this->statuses) {
       return qsprintf(
         $conn,
         'status IN (%Ls)',
         $this->statuses);
     }
     return null;
   }
 
   private function buildPrioritiesWhereClause(AphrontDatabaseConnection $conn) {
     if ($this->priorities) {
       return qsprintf(
         $conn,
         'priority IN (%Ld)',
         $this->priorities);
     }
 
     return null;
   }
 
   private function buildAuthorWhereClause(AphrontDatabaseConnection $conn) {
     if (!$this->authorPHIDs) {
       return null;
     }
 
     return qsprintf(
       $conn,
       'authorPHID in (%Ls)',
       $this->authorPHIDs);
   }
 
   private function buildOwnerWhereClause(AphrontDatabaseConnection $conn) {
     if (!$this->ownerPHIDs) {
       if ($this->includeUnowned === null) {
         return null;
       } else if ($this->includeUnowned) {
         return qsprintf(
           $conn,
           'ownerPHID IS NULL');
       } else {
         return qsprintf(
           $conn,
           'ownerPHID IS NOT NULL');
       }
     }
 
     if ($this->includeUnowned) {
       return qsprintf(
         $conn,
         'ownerPHID IN (%Ls) OR ownerPHID IS NULL',
         $this->ownerPHIDs);
     } else {
       return qsprintf(
         $conn,
         'ownerPHID IN (%Ls)',
         $this->ownerPHIDs);
     }
   }
 
   private function buildFullTextWhereClause(AphrontDatabaseConnection $conn) {
     if (!strlen($this->fullTextSearch)) {
       return null;
     }
 
     // In doing a fulltext search, we first find all the PHIDs that match the
     // fulltext search, and then use that to limit the rest of the search
     $fulltext_query = id(new PhabricatorSavedQuery())
       ->setEngineClassName('PhabricatorSearchApplicationSearchEngine')
       ->setParameter('query', $this->fullTextSearch);
 
     // NOTE: Setting this to something larger than 2^53 will raise errors in
     // ElasticSearch, and billions of results won't fit in memory anyway.
     $fulltext_query->setParameter('limit', 100000);
     $fulltext_query->setParameter('type', ManiphestPHIDTypeTask::TYPECONST);
 
     $engine = PhabricatorSearchEngineSelector::newSelector()->newEngine();
     $fulltext_results = $engine->executeSearch($fulltext_query);
 
     if (empty($fulltext_results)) {
       $fulltext_results = array(null);
     }
 
     return qsprintf(
       $conn,
       'phid IN (%Ls)',
       $fulltext_results);
   }
 
   private function buildSubscriberWhereClause(AphrontDatabaseConnection $conn) {
     if (!$this->subscriberPHIDs) {
       return null;
     }
 
     return qsprintf(
       $conn,
       'subscriber.subscriberPHID IN (%Ls)',
       $this->subscriberPHIDs);
   }
 
   private function buildProjectWhereClause(AphrontDatabaseConnection $conn) {
     if (!$this->projectPHIDs && !$this->includeNoProject) {
       return null;
     }
 
     $parts = array();
     if ($this->projectPHIDs) {
       $parts[] = qsprintf(
         $conn,
-        'project.projectPHID in (%Ls)',
+        'project.dst in (%Ls)',
         $this->projectPHIDs);
     }
     if ($this->includeNoProject) {
       $parts[] = qsprintf(
         $conn,
-        'project.projectPHID IS NULL');
+        'project.dst IS NULL');
     }
 
     return '('.implode(') OR (', $parts).')';
   }
 
   private function buildAnyProjectWhereClause(AphrontDatabaseConnection $conn) {
     if (!$this->anyProjectPHIDs) {
       return null;
     }
 
     return qsprintf(
       $conn,
-      'anyproject.projectPHID IN (%Ls)',
+      'anyproject.dst IN (%Ls)',
       $this->anyProjectPHIDs);
   }
 
   private function buildAnyUserProjectWhereClause(
     AphrontDatabaseConnection $conn) {
     if (!$this->anyUserProjectPHIDs) {
       return null;
     }
 
     $projects = id(new PhabricatorProjectQuery())
       ->setViewer($this->getViewer())
       ->withMemberPHIDs($this->anyUserProjectPHIDs)
       ->execute();
     $any_user_project_phids = mpull($projects, 'getPHID');
     if (!$any_user_project_phids) {
       throw new PhabricatorEmptyQueryException();
     }
 
     return qsprintf(
       $conn,
-      'anyproject.projectPHID IN (%Ls)',
+      'anyproject.dst IN (%Ls)',
       $any_user_project_phids);
   }
 
   private function buildXProjectWhereClause(AphrontDatabaseConnection $conn) {
     if (!$this->xprojectPHIDs) {
       return null;
     }
 
     return qsprintf(
       $conn,
-      'xproject.projectPHID IS NULL');
+      'xproject.dst IS NULL');
   }
 
   private function buildCustomOrderClause(AphrontDatabaseConnection $conn) {
     $order = array();
 
     switch ($this->groupBy) {
       case self::GROUP_NONE:
         break;
       case self::GROUP_PRIORITY:
         $order[] = 'priority';
         break;
       case self::GROUP_OWNER:
         $order[] = 'ownerOrdering';
         break;
       case self::GROUP_STATUS:
         $order[] = 'status';
         break;
       case self::GROUP_PROJECT:
         $order[] = '<group.project>';
         break;
       default:
         throw new Exception("Unknown group query '{$this->groupBy}'!");
     }
 
     switch ($this->orderBy) {
       case self::ORDER_PRIORITY:
         $order[] = 'priority';
         $order[] = 'subpriority';
         $order[] = 'dateModified';
         break;
       case self::ORDER_CREATED:
         $order[] = 'id';
         break;
       case self::ORDER_MODIFIED:
         $order[] = 'dateModified';
         break;
       case self::ORDER_TITLE:
         $order[] = 'title';
         break;
       default:
         throw new Exception("Unknown order query '{$this->orderBy}'!");
     }
 
     $order = array_unique($order);
 
     if (empty($order)) {
       return null;
     }
 
     $reverse = ($this->getBeforeID() xor $this->getReversePaging());
 
     foreach ($order as $k => $column) {
       switch ($column) {
         case 'subpriority':
         case 'ownerOrdering':
         case 'title':
           if ($reverse) {
             $order[$k] = "task.{$column} DESC";
           } else {
             $order[$k] = "task.{$column} ASC";
           }
           break;
         case '<group.project>':
           // Put "No Project" at the end of the list.
           if ($reverse) {
             $order[$k] =
               'projectGroupName.indexedObjectName IS NULL DESC, '.
               'projectGroupName.indexedObjectName DESC';
           } else {
             $order[$k] =
               'projectGroupName.indexedObjectName IS NULL ASC, '.
               'projectGroupName.indexedObjectName ASC';
           }
           break;
         default:
           if ($reverse) {
             $order[$k] = "task.{$column} ASC";
           } else {
             $order[$k] = "task.{$column} DESC";
           }
           break;
       }
     }
 
     return 'ORDER BY '.implode(', ', $order);
   }
 
   private function buildJoinsClause(AphrontDatabaseConnection $conn_r) {
-    $project_dao = new ManiphestTaskProject();
+    $edge_table = PhabricatorEdgeConfig::TABLE_NAME_EDGE;
 
     $joins = array();
 
     if ($this->projectPHIDs || $this->includeNoProject) {
       $joins[] = qsprintf(
         $conn_r,
-        '%Q JOIN %T project ON project.taskPHID = task.phid',
+        '%Q JOIN %T project ON project.src = task.phid
+          AND project.type = %d',
         ($this->includeNoProject ? 'LEFT' : ''),
-        $project_dao->getTableName());
+        $edge_table,
+        PhabricatorProjectObjectHasProjectEdgeType::EDGECONST);
     }
 
     if ($this->anyProjectPHIDs || $this->anyUserProjectPHIDs) {
       $joins[] = qsprintf(
         $conn_r,
-        'JOIN %T anyproject ON anyproject.taskPHID = task.phid',
-        $project_dao->getTableName());
+        'JOIN %T anyproject ON anyproject.src = task.phid
+          AND anyproject.type = %d',
+        $edge_table,
+        PhabricatorProjectObjectHasProjectEdgeType::EDGECONST);
     }
 
     if ($this->xprojectPHIDs) {
       $joins[] = qsprintf(
         $conn_r,
-        'LEFT JOIN %T xproject ON xproject.taskPHID = task.phid
-          AND xproject.projectPHID IN (%Ls)',
-        $project_dao->getTableName(),
+        'LEFT JOIN %T xproject ON xproject.src = task.phid
+          AND xproject.type = %d
+          AND xproject.dst IN (%Ls)',
+        $edge_table,
+        PhabricatorProjectObjectHasProjectEdgeType::EDGECONST,
         $this->xprojectPHIDs);
     }
 
     if ($this->subscriberPHIDs) {
       $subscriber_dao = new ManiphestTaskSubscriber();
       $joins[] = qsprintf(
         $conn_r,
         'JOIN %T subscriber ON subscriber.taskPHID = task.phid',
         $subscriber_dao->getTableName());
     }
 
     switch ($this->groupBy) {
       case self::GROUP_PROJECT:
         $ignore_group_phids = $this->getIgnoreGroupedProjectPHIDs();
         if ($ignore_group_phids) {
           $joins[] = qsprintf(
             $conn_r,
-            'LEFT JOIN %T projectGroup ON task.phid = projectGroup.taskPHID
-              AND projectGroup.projectPHID NOT IN (%Ls)',
-            $project_dao->getTableName(),
+            'LEFT JOIN %T projectGroup ON task.phid = projectGroup.src
+              AND projectGroup.type = %d
+              AND projectGroup.dst NOT IN (%Ls)',
+            $edge_table,
+            PhabricatorProjectObjectHasProjectEdgeType::EDGECONST,
             $ignore_group_phids);
         } else {
           $joins[] = qsprintf(
             $conn_r,
-            'LEFT JOIN %T projectGroup ON task.phid = projectGroup.taskPHID',
-            $project_dao->getTableName());
+            'LEFT JOIN %T projectGroup ON task.phid = projectGroup.src
+              AND projectGroup.type = %d',
+            $edge_table,
+            PhabricatorProjectObjectHasProjectEdgeType::EDGECONST);
         }
         $joins[] = qsprintf(
           $conn_r,
           'LEFT JOIN %T projectGroupName
-            ON projectGroup.projectPHID = projectGroupName.indexedObjectPHID',
+            ON projectGroup.dst = projectGroupName.indexedObjectPHID',
           id(new ManiphestNameIndex())->getTableName());
         break;
     }
 
     $joins[] = $this->buildApplicationSearchJoinClause($conn_r);
 
     return implode(' ', $joins);
   }
 
   private function buildGroupClause(AphrontDatabaseConnection $conn_r) {
     $joined_multiple_rows = (count($this->projectPHIDs) > 1) ||
                             (count($this->anyProjectPHIDs) > 1) ||
                             ($this->getApplicationSearchMayJoinMultipleRows());
 
     $joined_project_name = ($this->groupBy == self::GROUP_PROJECT);
 
     // If we're joining multiple rows, we need to group the results by the
     // task IDs.
     if ($joined_multiple_rows) {
       if ($joined_project_name) {
-        return 'GROUP BY task.phid, projectGroup.projectPHID';
+        return 'GROUP BY task.phid, projectGroup.dst';
       } else {
         return 'GROUP BY task.phid';
       }
     } else {
       return '';
     }
   }
 
   /**
    * Return project PHIDs which we should ignore when grouping tasks by
    * project. For example, if a user issues a query like:
    *
    *   Tasks in all projects: Frontend, Bugs
    *
    * ...then we don't show "Frontend" or "Bugs" groups in the result set, since
    * they're meaningless as all results are in both groups.
    *
    * Similarly, for queries like:
    *
    *   Tasks in any projects: Public Relations
    *
    * ...we ignore the single project, as every result is in that project. (In
    * the case that there are several "any" projects, we do not ignore them.)
    *
    * @return list<phid> Project PHIDs which should be ignored in query
    *                    construction.
    */
   private function getIgnoreGroupedProjectPHIDs() {
     $phids = array();
 
     if ($this->projectPHIDs) {
       $phids[] = $this->projectPHIDs;
     }
 
     if (count($this->anyProjectPHIDs) == 1) {
       $phids[] = $this->anyProjectPHIDs;
     }
 
     // Maybe we should also exclude the "excludeProjectPHIDs"? It won't
     // impact the results, but we might end up with a better query plan.
     // Investigate this on real data? This is likely very rare.
 
     return array_mergev($phids);
   }
 
   private function loadCursorObject($id) {
     $results = id(new ManiphestTaskQuery())
       ->setViewer($this->getPagingViewer())
       ->withIDs(array((int)$id))
       ->execute();
     return head($results);
   }
 
   protected function getPagingValue($result) {
     $id = $result->getID();
 
     switch ($this->groupBy) {
       case self::GROUP_NONE:
         return $id;
       case self::GROUP_PRIORITY:
         return $id.'.'.$result->getPriority();
       case self::GROUP_OWNER:
         return rtrim($id.'.'.$result->getOwnerPHID(), '.');
       case self::GROUP_STATUS:
         return $id.'.'.$result->getStatus();
       case self::GROUP_PROJECT:
         return rtrim($id.'.'.$result->getGroupByProjectPHID(), '.');
       default:
         throw new Exception("Unknown group query '{$this->groupBy}'!");
     }
   }
 
   protected function buildPagingClause(AphrontDatabaseConnection $conn_r) {
     $default = parent::buildPagingClause($conn_r);
 
     $before_id = $this->getBeforeID();
     $after_id = $this->getAfterID();
 
     if (!$before_id && !$after_id) {
       return $default;
     }
 
     $cursor_id = nonempty($before_id, $after_id);
     $cursor_parts = explode('.', $cursor_id, 2);
     $task_id = $cursor_parts[0];
     $group_id = idx($cursor_parts, 1);
 
     $cursor = $this->loadCursorObject($task_id);
     if (!$cursor) {
       return null;
     }
 
     $columns = array();
 
     switch ($this->groupBy) {
       case self::GROUP_NONE:
         break;
       case self::GROUP_PRIORITY:
         $columns[] = array(
           'name' => 'task.priority',
           'value' => (int)$group_id,
           'type' => 'int',
         );
         break;
       case self::GROUP_OWNER:
         $columns[] = array(
           'name' => '(task.ownerOrdering IS NULL)',
           'value' => (int)(strlen($group_id) ? 0 : 1),
           'type' => 'int',
         );
         if ($group_id) {
           $paging_users = id(new PhabricatorPeopleQuery())
             ->setViewer($this->getViewer())
             ->withPHIDs(array($group_id))
             ->execute();
           if (!$paging_users) {
             return null;
           }
           $columns[] = array(
             'name' => 'task.ownerOrdering',
             'value' => head($paging_users)->getUsername(),
             'type' => 'string',
             'reverse' => true,
           );
         }
         break;
       case self::GROUP_STATUS:
         $columns[] = array(
           'name' => 'task.status',
           'value' => $group_id,
           'type' => 'string',
         );
         break;
       case self::GROUP_PROJECT:
         $columns[] = array(
           'name' => '(projectGroupName.indexedObjectName IS NULL)',
           'value' => (int)(strlen($group_id) ? 0 : 1),
           'type' => 'int',
         );
         if ($group_id) {
           $paging_projects = id(new PhabricatorProjectQuery())
             ->setViewer($this->getViewer())
             ->withPHIDs(array($group_id))
             ->execute();
           if (!$paging_projects) {
             return null;
           }
           $columns[] = array(
             'name' => 'projectGroupName.indexedObjectName',
             'value' => head($paging_projects)->getName(),
             'type' => 'string',
             'reverse' => true,
           );
         }
         break;
       default:
         throw new Exception("Unknown group query '{$this->groupBy}'!");
     }
 
     switch ($this->orderBy) {
       case self::ORDER_PRIORITY:
         if ($this->groupBy != self::GROUP_PRIORITY) {
           $columns[] = array(
             'name' => 'task.priority',
             'value' => (int)$cursor->getPriority(),
             'type' => 'int',
           );
         }
         $columns[] = array(
           'name' => 'task.subpriority',
           'value' => (int)$cursor->getSubpriority(),
           'type' => 'int',
           'reverse' => true,
         );
         $columns[] = array(
           'name' => 'task.dateModified',
           'value' => (int)$cursor->getDateModified(),
           'type' => 'int',
         );
         break;
       case self::ORDER_CREATED:
         $columns[] = array(
           'name' => 'task.id',
           'value' => (int)$cursor->getID(),
           'type' => 'int',
         );
         break;
       case self::ORDER_MODIFIED:
         $columns[] = array(
           'name' => 'task.dateModified',
           'value' => (int)$cursor->getDateModified(),
           'type' => 'int',
         );
         break;
       case self::ORDER_TITLE:
         $columns[] = array(
           'name' => 'task.title',
           'value' => $cursor->getTitle(),
           'type' => 'string',
         );
         $columns[] = array(
           'name' => 'task.id',
           'value' => $cursor->getID(),
           'type' => 'int',
         );
         break;
       default:
         throw new Exception("Unknown order query '{$this->orderBy}'!");
     }
 
     return $this->buildPagingClauseFromMultipleColumns(
       $conn_r,
       $columns,
       array(
         'reversed' => (bool)($before_id xor $this->getReversePaging()),
       ));
   }
 
   protected function getApplicationSearchObjectPHIDColumn() {
     return 'task.phid';
   }
 
   public function getQueryApplicationClass() {
     return 'PhabricatorApplicationManiphest';
   }
 
 }
diff --git a/src/applications/maniphest/storage/ManiphestTaskProject.php b/src/applications/maniphest/storage/ManiphestTaskProject.php
index f26627a01b..eda914579b 100644
--- a/src/applications/maniphest/storage/ManiphestTaskProject.php
+++ b/src/applications/maniphest/storage/ManiphestTaskProject.php
@@ -1,49 +1,48 @@
 <?php
 
 /**
  * This is a DAO for the Task -> Project table, which denormalizes the
  * relationship between tasks and projects into a link table so it can be
  * efficiently queried. This table is not authoritative; the projectPHIDs field
  * of ManiphestTask is. The rows in this table are regenerated when transactions
  * are applied to tasks which affected their associated projects.
  */
 final class ManiphestTaskProject extends ManiphestDAO {
 
   protected $taskPHID;
   protected $projectPHID;
 
   public function getConfiguration() {
     return array(
       self::CONFIG_IDS          => self::IDS_MANUAL,
       self::CONFIG_TIMESTAMPS   => false,
     );
   }
 
   public static function updateTaskProjects(ManiphestTask $task) {
-    $dao = new ManiphestTaskProject();
-    $conn = $dao->establishConnection('w');
-
-    $sql = array();
-    foreach ($task->getProjectPHIDs() as $project_phid) {
-      $sql[] = qsprintf(
-        $conn,
-        '(%s, %s)',
-        $task->getPHID(),
-        $project_phid);
+    $edge_type = PhabricatorProjectObjectHasProjectEdgeType::EDGECONST;
+
+    $old_phids = PhabricatorEdgeQuery::loadDestinationPHIDs(
+      $task->getPHID(),
+      $edge_type);
+    $new_phids = $task->getProjectPHIDs();
+
+    $add_phids = array_diff($new_phids, $old_phids);
+    $rem_phids = array_diff($old_phids, $new_phids);
+
+    if (!$add_phids && !$rem_phids) {
+      return;
     }
 
-    queryfx(
-      $conn,
-      'DELETE FROM %T WHERE taskPHID = %s',
-      $dao->getTableName(),
-      $task->getPHID());
-    if ($sql) {
-      queryfx(
-        $conn,
-        'INSERT INTO %T (taskPHID, projectPHID) VALUES %Q',
-        $dao->getTableName(),
-        implode(', ', $sql));
+
+    $editor = new PhabricatorEdgeEditor();
+    foreach ($add_phids as $phid) {
+      $editor->addEdge($task->getPHID(), $edge_type, $phid);
+    }
+    foreach ($rem_phids as $phid) {
+      $editor->remEdge($task->getPHID(), $edge_type, $phid);
     }
+    $editor->save();
   }
 
 }