@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.) hq.recaptime.dev/wiki/Phorge
phorge phabricator

Materialize parent project memberships

Summary:
Ref T10010. Subprojects have the following general membership rule: if you are a member of a subproject ("Engineering > Backend"), you are also a member of the parent project.

It would be unreasonably difficult to implement this rule directly in SQL when querying `withMemberPHIDs()`, because we'd have to do an arbitrarily large number of arbitrarily deep joins, or fetch and then requery a lot of data.

Instead, introduce "materailized members", which are just a copy of all the effective members of a project. When a subproject has a membership change, we go recompute the effective membership of all the parent projects. Then we can just JOIN to satisfy `withMemberPHIDs()`.

Having this process avialable will also be useful in the future, when a project's membership might be defined by some external source.

Also make milestones mostly work like we'd expect them to with respect to membership and visibility.

Test Plan:
- Added and executed unit tests.
- Changed project members, verified materialized members populated correctly in the database.

Reviewers: chad

Reviewed By: chad

Maniphest Tasks: T10010

Differential Revision: https://secure.phabricator.com/D14863

+268 -9
+4
src/__phutil_library_map__.php
··· 2855 2855 'PhabricatorProjectLogicalOrNotDatasource' => 'applications/project/typeahead/PhabricatorProjectLogicalOrNotDatasource.php', 2856 2856 'PhabricatorProjectLogicalUserDatasource' => 'applications/project/typeahead/PhabricatorProjectLogicalUserDatasource.php', 2857 2857 'PhabricatorProjectLogicalViewerDatasource' => 'applications/project/typeahead/PhabricatorProjectLogicalViewerDatasource.php', 2858 + 'PhabricatorProjectMaterializedMemberEdgeType' => 'applications/project/edge/PhabricatorProjectMaterializedMemberEdgeType.php', 2858 2859 'PhabricatorProjectMemberOfProjectEdgeType' => 'applications/project/edge/PhabricatorProjectMemberOfProjectEdgeType.php', 2859 2860 'PhabricatorProjectMembersDatasource' => 'applications/project/typeahead/PhabricatorProjectMembersDatasource.php', 2860 2861 'PhabricatorProjectMembersEditController' => 'applications/project/controller/PhabricatorProjectMembersEditController.php', ··· 2889 2890 'PhabricatorProjectsEditEngineExtension' => 'applications/project/engineextension/PhabricatorProjectsEditEngineExtension.php', 2890 2891 'PhabricatorProjectsEditField' => 'applications/transactions/editfield/PhabricatorProjectsEditField.php', 2891 2892 'PhabricatorProjectsFulltextEngineExtension' => 'applications/project/engineextension/PhabricatorProjectsFulltextEngineExtension.php', 2893 + 'PhabricatorProjectsMembershipIndexEngineExtension' => 'applications/project/engineextension/PhabricatorProjectsMembershipIndexEngineExtension.php', 2892 2894 'PhabricatorProjectsPolicyRule' => 'applications/project/policyrule/PhabricatorProjectsPolicyRule.php', 2893 2895 'PhabricatorProjectsSearchEngineAttachment' => 'applications/project/engineextension/PhabricatorProjectsSearchEngineAttachment.php', 2894 2896 'PhabricatorProjectsSearchEngineExtension' => 'applications/project/engineextension/PhabricatorProjectsSearchEngineExtension.php', ··· 7189 7191 'PhabricatorProjectLogicalOrNotDatasource' => 'PhabricatorTypeaheadCompositeDatasource', 7190 7192 'PhabricatorProjectLogicalUserDatasource' => 'PhabricatorTypeaheadCompositeDatasource', 7191 7193 'PhabricatorProjectLogicalViewerDatasource' => 'PhabricatorTypeaheadDatasource', 7194 + 'PhabricatorProjectMaterializedMemberEdgeType' => 'PhabricatorEdgeType', 7192 7195 'PhabricatorProjectMemberOfProjectEdgeType' => 'PhabricatorEdgeType', 7193 7196 'PhabricatorProjectMembersDatasource' => 'PhabricatorTypeaheadCompositeDatasource', 7194 7197 'PhabricatorProjectMembersEditController' => 'PhabricatorProjectController', ··· 7226 7229 'PhabricatorProjectsEditEngineExtension' => 'PhabricatorEditEngineExtension', 7227 7230 'PhabricatorProjectsEditField' => 'PhabricatorTokenizerEditField', 7228 7231 'PhabricatorProjectsFulltextEngineExtension' => 'PhabricatorFulltextEngineExtension', 7232 + 'PhabricatorProjectsMembershipIndexEngineExtension' => 'PhabricatorIndexEngineExtension', 7229 7233 'PhabricatorProjectsPolicyRule' => 'PhabricatorPolicyRule', 7230 7234 'PhabricatorProjectsSearchEngineAttachment' => 'PhabricatorSearchEngineAttachment', 7231 7235 'PhabricatorProjectsSearchEngineExtension' => 'PhabricatorSearchEngineExtension',
+71 -1
src/applications/project/__tests__/PhabricatorProjectCoreTestCase.php
··· 187 187 $this->assertEqual(2, count($projects)); 188 188 } 189 189 190 + public function testMemberMaterialization() { 191 + $material_type = PhabricatorProjectMaterializedMemberEdgeType::EDGECONST; 192 + 193 + $user = $this->createUser(); 194 + $user->save(); 195 + 196 + $parent = $this->createProject($user); 197 + $child = $this->createProject($user, $parent); 198 + 199 + $this->joinProject($child, $user); 200 + 201 + $parent_material = PhabricatorEdgeQuery::loadDestinationPHIDs( 202 + $parent->getPHID(), 203 + $material_type); 204 + 205 + $this->assertEqual( 206 + array($user->getPHID()), 207 + $parent_material); 208 + } 209 + 210 + public function testMilestones() { 211 + $user = $this->createUser(); 212 + $user->save(); 213 + 214 + $parent = $this->createProject($user); 215 + 216 + $m1 = $this->createProject($user, $parent, true); 217 + $m2 = $this->createProject($user, $parent, true); 218 + $m3 = $this->createProject($user, $parent, true); 219 + 220 + $this->assertEqual(1, $m1->getMilestoneNumber()); 221 + $this->assertEqual(2, $m2->getMilestoneNumber()); 222 + $this->assertEqual(3, $m3->getMilestoneNumber()); 223 + } 224 + 225 + public function testMilestoneMembership() { 226 + $user = $this->createUser(); 227 + $user->save(); 228 + 229 + $parent = $this->createProject($user); 230 + $milestone = $this->createProject($user, $parent, true); 231 + 232 + $this->joinProject($parent, $user); 233 + 234 + $milestone = id(new PhabricatorProjectQuery()) 235 + ->setViewer($user) 236 + ->withPHIDs(array($milestone->getPHID())) 237 + ->executeOne(); 238 + 239 + $this->assertTrue($milestone->isUserMember($user->getPHID())); 240 + 241 + $milestone = id(new PhabricatorProjectQuery()) 242 + ->setViewer($user) 243 + ->withPHIDs(array($milestone->getPHID())) 244 + ->needMembers(true) 245 + ->executeOne(); 246 + 247 + $this->assertEqual( 248 + array($user->getPHID()), 249 + $milestone->getMemberPHIDs()); 250 + } 251 + 190 252 public function testParentProject() { 191 253 $user = $this->createUser(); 192 254 $user->save(); ··· 396 458 397 459 private function createProject( 398 460 PhabricatorUser $user, 399 - PhabricatorProject $parent = null) { 461 + PhabricatorProject $parent = null, 462 + $is_milestone = false) { 400 463 401 464 $project = PhabricatorProject::initializeNewProject($user); 465 + 402 466 403 467 $name = pht('Test Project %d', mt_rand()); 404 468 ··· 412 476 $xactions[] = id(new PhabricatorProjectTransaction()) 413 477 ->setTransactionType(PhabricatorProjectTransaction::TYPE_PARENT) 414 478 ->setNewValue($parent->getPHID()); 479 + } 480 + 481 + if ($is_milestone) { 482 + $xactions[] = id(new PhabricatorProjectTransaction()) 483 + ->setTransactionType(PhabricatorProjectTransaction::TYPE_MILESTONE) 484 + ->setNewValue(true); 415 485 } 416 486 417 487 $this->applyTransactions($project, $user, $xactions);
+8
src/applications/project/edge/PhabricatorProjectMaterializedMemberEdgeType.php
··· 1 + <?php 2 + 3 + final class PhabricatorProjectMaterializedMemberEdgeType 4 + extends PhabricatorEdgeType { 5 + 6 + const EDGECONST = 60; 7 + 8 + }
+50
src/applications/project/editor/PhabricatorProjectTransactionEditor.php
··· 27 27 $types[] = PhabricatorProjectTransaction::TYPE_COLOR; 28 28 $types[] = PhabricatorProjectTransaction::TYPE_LOCKED; 29 29 $types[] = PhabricatorProjectTransaction::TYPE_PARENT; 30 + $types[] = PhabricatorProjectTransaction::TYPE_MILESTONE; 30 31 31 32 return $types; 32 33 } ··· 54 55 case PhabricatorProjectTransaction::TYPE_LOCKED: 55 56 return (int)$object->getIsMembershipLocked(); 56 57 case PhabricatorProjectTransaction::TYPE_PARENT: 58 + case PhabricatorProjectTransaction::TYPE_MILESTONE: 57 59 return null; 58 60 } 59 61 ··· 74 76 case PhabricatorProjectTransaction::TYPE_LOCKED: 75 77 case PhabricatorProjectTransaction::TYPE_PARENT: 76 78 return $xaction->getNewValue(); 79 + case PhabricatorProjectTransaction::TYPE_MILESTONE: 80 + $current = queryfx_one( 81 + $object->establishConnection('w'), 82 + 'SELECT MAX(milestoneNumber) n 83 + FROM %T 84 + WHERE parentProjectPHID = %s', 85 + $object->getTableName(), 86 + $object->getParentProject()->getPHID()); 87 + if (!$current) { 88 + $number = 1; 89 + } else { 90 + $number = (int)$current['n'] + 1; 91 + } 92 + return $number; 77 93 } 78 94 79 95 return parent::getCustomTransactionNewValue($object, $xaction); ··· 108 124 return; 109 125 case PhabricatorProjectTransaction::TYPE_PARENT: 110 126 $object->setParentProjectPHID($xaction->getNewValue()); 127 + return; 128 + case PhabricatorProjectTransaction::TYPE_MILESTONE: 129 + $object->setMilestoneNumber($xaction->getNewValue()); 111 130 return; 112 131 } 113 132 ··· 161 180 case PhabricatorProjectTransaction::TYPE_COLOR: 162 181 case PhabricatorProjectTransaction::TYPE_LOCKED: 163 182 case PhabricatorProjectTransaction::TYPE_PARENT: 183 + case PhabricatorProjectTransaction::TYPE_MILESTONE: 164 184 return; 165 185 } 166 186 ··· 590 610 ->setProjectPHID($object->getPHID()) 591 611 ->save(); 592 612 } 613 + 614 + 615 + protected function applyFinalEffects( 616 + PhabricatorLiskDAO $object, 617 + array $xactions) { 618 + 619 + $materialize = false; 620 + foreach ($xactions as $xaction) { 621 + switch ($xaction->getTransactionType()) { 622 + case PhabricatorTransactions::TYPE_EDGE: 623 + switch ($xaction->getMetadataValue('edge:type')) { 624 + case PhabricatorProjectProjectHasMemberEdgeType::EDGECONST: 625 + $materialize = true; 626 + break; 627 + } 628 + break; 629 + case PhabricatorProjectTransaction::TYPE_PARENT: 630 + $materialize = true; 631 + break; 632 + } 633 + } 634 + 635 + if ($materialize) { 636 + id(new PhabricatorProjectsMembershipIndexEngineExtension()) 637 + ->rematerialize($object); 638 + } 639 + 640 + return parent::applyFinalEffects($object, $xactions); 641 + } 642 + 593 643 }
+98
src/applications/project/engineextension/PhabricatorProjectsMembershipIndexEngineExtension.php
··· 1 + <?php 2 + 3 + final class PhabricatorProjectsMembershipIndexEngineExtension 4 + extends PhabricatorIndexEngineExtension { 5 + 6 + const EXTENSIONKEY = 'project.members'; 7 + 8 + public function getExtensionName() { 9 + return pht('Project Members'); 10 + } 11 + 12 + public function shouldIndexObject($object) { 13 + if (!($object instanceof PhabricatorProject)) { 14 + return false; 15 + } 16 + 17 + return true; 18 + } 19 + 20 + public function indexObject( 21 + PhabricatorIndexEngine $engine, 22 + $object) { 23 + 24 + $this->rematerialize($object); 25 + } 26 + 27 + public function rematerialize(PhabricatorProject $project) { 28 + $materialize = $project->getAncestorProjects(); 29 + array_unshift($materialize, $project); 30 + 31 + foreach ($materialize as $project) { 32 + $this->materializeProject($project); 33 + } 34 + } 35 + 36 + private function materializeProject(PhabricatorProject $project) { 37 + if ($project->isMilestone()) { 38 + return; 39 + } 40 + 41 + $material_type = PhabricatorProjectMaterializedMemberEdgeType::EDGECONST; 42 + $member_type = PhabricatorProjectProjectHasMemberEdgeType::EDGECONST; 43 + 44 + $project_phid = $project->getPHID(); 45 + 46 + $descendants = id(new PhabricatorProjectQuery()) 47 + ->setViewer($this->getViewer()) 48 + ->withAncestorProjectPHIDs(array($project->getPHID())) 49 + ->withIsMilestone(false) 50 + ->withHasSubprojects(false) 51 + ->execute(); 52 + $descendant_phids = mpull($descendants, 'getPHID'); 53 + 54 + if ($descendant_phids) { 55 + $source_phids = $descendant_phids; 56 + $has_subprojects = true; 57 + } else { 58 + $source_phids = array($project->getPHID()); 59 + $has_subprojects = false; 60 + } 61 + 62 + $conn_w = $project->establishConnection('w'); 63 + 64 + $project->openTransaction(); 65 + 66 + // Delete any existing materialized member edges. 67 + queryfx( 68 + $conn_w, 69 + 'DELETE FROM %T WHERE src = %s AND type = %s', 70 + PhabricatorEdgeConfig::TABLE_NAME_EDGE, 71 + $project_phid, 72 + $material_type); 73 + 74 + // Copy current member edges to create new materialized edges. 75 + queryfx( 76 + $conn_w, 77 + 'INSERT IGNORE INTO %T (src, type, dst, dateCreated, seq) 78 + SELECT %s, %d, dst, dateCreated, seq FROM %T 79 + WHERE src IN (%Ls) AND type = %d', 80 + PhabricatorEdgeConfig::TABLE_NAME_EDGE, 81 + $project_phid, 82 + $material_type, 83 + PhabricatorEdgeConfig::TABLE_NAME_EDGE, 84 + $source_phids, 85 + $member_type); 86 + 87 + // Update the hasSubprojects flag. 88 + queryfx( 89 + $conn_w, 90 + 'UPDATE %T SET hasSubprojects = %d WHERE id = %d', 91 + $project->getTableName(), 92 + (int)$has_subprojects, 93 + $project->getID()); 94 + 95 + $project->saveTransaction(); 96 + } 97 + 98 + }
+36 -8
src/applications/project/query/PhabricatorProjectQuery.php
··· 14 14 private $ancestorPHIDs; 15 15 private $parentPHIDs; 16 16 private $isMilestone; 17 + private $hasSubprojects; 17 18 private $minDepth; 18 19 private $maxDepth; 19 20 ··· 89 90 return $this; 90 91 } 91 92 93 + public function withHasSubprojects($has_subprojects) { 94 + $this->hasSubprojects = $has_subprojects; 95 + return $this; 96 + } 97 + 98 + public function getProperty() { 99 + return $this->property; 100 + } 101 + 92 102 public function withDepthBetween($min, $max) { 93 103 $this->minDepth = $min; 94 104 $this->maxDepth = $max; ··· 156 166 } 157 167 158 168 protected function willFilterPage(array $projects) { 159 - $project_phids = array(); 160 169 $ancestor_paths = array(); 161 - 162 170 foreach ($projects as $project) { 163 - $project_phids[] = $project->getPHID(); 164 - 165 171 foreach ($project->getAncestorProjectPaths() as $path) { 166 172 $ancestor_paths[$path] = $path; 167 173 } ··· 178 184 $projects = $this->linkProjectGraph($projects, $ancestors); 179 185 180 186 $viewer_phid = $this->getViewer()->getPHID(); 181 - $project_phids = mpull($projects, 'getPHID'); 182 187 183 188 $member_type = PhabricatorProjectProjectHasMemberEdgeType::EDGECONST; 184 189 $watcher_type = PhabricatorObjectHasWatcherEdgeType::EDGECONST; ··· 189 194 $types[] = $watcher_type; 190 195 } 191 196 197 + $all_sources = array(); 198 + foreach ($projects as $project) { 199 + if ($project->isMilestone()) { 200 + $phid = $project->getParentProjectPHID(); 201 + } else { 202 + $phid = $project->getPHID(); 203 + } 204 + $all_sources[$phid] = $phid; 205 + } 206 + 192 207 $edge_query = id(new PhabricatorEdgeQuery()) 193 - ->withSourcePHIDs($project_phids) 208 + ->withSourcePHIDs($all_sources) 194 209 ->withEdgeTypes($types); 195 210 196 211 // If we only need to know if the viewer is a member, we can restrict ··· 205 220 foreach ($projects as $project) { 206 221 $project_phid = $project->getPHID(); 207 222 223 + if ($project->isMilestone()) { 224 + $source_phids = array($project->getParentProjectPHID()); 225 + } else { 226 + $source_phids = array($project_phid); 227 + } 228 + 208 229 $member_phids = $edge_query->getDestinationPHIDs( 209 - array($project_phid), 230 + $source_phids, 210 231 array($member_type)); 211 232 212 233 if (in_array($viewer_phid, $member_phids)) { ··· 219 240 220 241 if ($this->needWatchers) { 221 242 $watcher_phids = $edge_query->getDestinationPHIDs( 222 - array($project_phid), 243 + $source_phids, 223 244 array($watcher_type)); 224 245 $project->attachWatcherPHIDs($watcher_phids); 225 246 $project->setIsUserWatcher( ··· 406 427 $conn, 407 428 'milestoneNumber IS NULL'); 408 429 } 430 + } 431 + 432 + if ($this->hasSubprojects !== null) { 433 + $where[] = qsprintf( 434 + $conn, 435 + 'hasSubprojects = %d', 436 + (int)$this->hasSubprojects); 409 437 } 410 438 411 439 if ($this->minDepth !== null) {
+1
src/applications/project/storage/PhabricatorProjectTransaction.php
··· 11 11 const TYPE_COLOR = 'project:color'; 12 12 const TYPE_LOCKED = 'project:locked'; 13 13 const TYPE_PARENT = 'project:parent'; 14 + const TYPE_MILESTONE = 'project:milestone'; 14 15 15 16 // NOTE: This is deprecated, members are just a normal edge now. 16 17 const TYPE_MEMBERS = 'project:members';