diff --git a/src/applications/base/PhabricatorApplication.php b/src/applications/base/PhabricatorApplication.php index 9526fb1442..407f3171c0 100644 --- a/src/applications/base/PhabricatorApplication.php +++ b/src/applications/base/PhabricatorApplication.php @@ -1,656 +1,656 @@ pht('Core Applications'), self::GROUP_UTILITIES => pht('Utilities'), self::GROUP_ADMIN => pht('Administration'), self::GROUP_DEVELOPER => pht('Developer Tools'), ); } final public function getApplicationName() { return 'application'; } final public function getTableName() { return 'application_application'; } final protected function getConfiguration() { return array( self::CONFIG_AUX_PHID => true, ) + parent::getConfiguration(); } final public function generatePHID() { return $this->getPHID(); } final public function save() { // When "save()" is called on applications, we just return without // actually writing anything to the database. return $this; } /* -( Application Information )-------------------------------------------- */ abstract public function getName(); public function getShortDescription() { return pht('%s Application', $this->getName()); } final public function isInstalled() { if (!$this->canUninstall()) { return true; } $prototypes = PhabricatorEnv::getEnvConfig('phabricator.show-prototypes'); if (!$prototypes && $this->isPrototype()) { return false; } $uninstalled = PhabricatorEnv::getEnvConfig( 'phabricator.uninstalled-applications'); return empty($uninstalled[get_class($this)]); } public function isPrototype() { return false; } /** * Return `true` if this application should never appear in application lists * in the UI. Primarily intended for unit test applications or other * pseudo-applications. * * Few applications should be unlisted. For most applications, use * @{method:isLaunchable} to hide them from main launch views instead. * * @return bool True to remove application from UI lists. */ public function isUnlisted() { return false; } /** * Return `true` if this application is a normal application with a base * URI and a web interface. * * Launchable applications can be pinned to the home page, and show up in the * "Launcher" view of the Applications application. Making an application * unlaunchable prevents pinning and hides it from this view. * * Usually, an application should be marked unlaunchable if: * * - it is available on every page anyway (like search); or * - it does not have a web interface (like subscriptions); or * - it is still pre-release and being intentionally buried. * * To hide applications more completely, use @{method:isUnlisted}. * * @return bool True if the application is launchable. */ public function isLaunchable() { return true; } /** * Return `true` if this application should be pinned by default. * * Users who have not yet set preferences see a default list of applications. * * @param PhabricatorUser User viewing the pinned application list. * @return bool True if this application should be pinned by default. */ public function isPinnedByDefault(PhabricatorUser $viewer) { return false; } /** * Returns true if an application is first-party (developed by Phacility) * and false otherwise. * * @return bool True if this application is developed by Phacility. */ final public function isFirstParty() { $where = id(new ReflectionClass($this))->getFileName(); $root = phutil_get_library_root('phabricator'); if (!Filesystem::isDescendant($where, $root)) { return false; } if (Filesystem::isDescendant($where, $root.'/extensions')) { return false; } return true; } public function canUninstall() { return true; } final public function getPHID() { return 'PHID-APPS-'.get_class($this); } public function getTypeaheadURI() { return $this->isLaunchable() ? $this->getBaseURI() : null; } public function getBaseURI() { return null; } final public function getApplicationURI($path = '') { return $this->getBaseURI().ltrim($path, '/'); } public function getIcon() { return 'fa-puzzle-piece'; } public function getApplicationOrder() { return PHP_INT_MAX; } public function getApplicationGroup() { return self::GROUP_CORE; } public function getTitleGlyph() { return null; } final public function getHelpMenuItems(PhabricatorUser $viewer) { $items = array(); $articles = $this->getHelpDocumentationArticles($viewer); if ($articles) { foreach ($articles as $article) { $item = id(new PhabricatorActionView()) ->setName($article['name']) ->setHref($article['href']) ->addSigil('help-item') ->setOpenInNewWindow(true); $items[] = $item; } } $command_specs = $this->getMailCommandObjects(); if ($command_specs) { foreach ($command_specs as $key => $spec) { $object = $spec['object']; $class = get_class($this); $href = '/applications/mailcommands/'.$class.'/'.$key.'/'; $item = id(new PhabricatorActionView()) ->setName($spec['name']) ->setHref($href) ->addSigil('help-item') ->setOpenInNewWindow(true); $items[] = $item; } } if ($items) { $divider = id(new PhabricatorActionView()) ->addSigil('help-item') ->setType(PhabricatorActionView::TYPE_DIVIDER); array_unshift($items, $divider); } return array_values($items); } public function getHelpDocumentationArticles(PhabricatorUser $viewer) { return array(); } public function getOverview() { return null; } public function getEventListeners() { return array(); } public function getRemarkupRules() { return array(); } public function getQuicksandURIPatternBlacklist() { return array(); } public function getMailCommandObjects() { return array(); } /* -( URI Routing )-------------------------------------------------------- */ public function getRoutes() { return array(); } public function getResourceRoutes() { return array(); } /* -( Email Integration )-------------------------------------------------- */ public function supportsEmailIntegration() { return false; } final protected function getInboundEmailSupportLink() { return PhabricatorEnv::getDoclink('Configuring Inbound Email'); } public function getAppEmailBlurb() { throw new PhutilMethodNotImplementedException(); } /* -( Fact Integration )--------------------------------------------------- */ public function getFactObjectsForAnalysis() { return array(); } /* -( UI Integration )----------------------------------------------------- */ /** * You can provide an optional piece of flavor text for the application. This * is currently rendered in application launch views if the application has no * status elements. * * @return string|null Flavor text. * @task ui */ public function getFlavorText() { return null; } /** * Build items for the main menu. * * @param PhabricatorUser The viewing user. * @param AphrontController The current controller. May be null for special * pages like 404, exception handlers, etc. * @return list List of menu items. * @task ui */ public function buildMainMenuItems( PhabricatorUser $user, PhabricatorController $controller = null) { return array(); } /* -( Application Management )--------------------------------------------- */ final public static function getByClass($class_name) { $selected = null; $applications = self::getAllApplications(); foreach ($applications as $application) { if (get_class($application) == $class_name) { $selected = $application; break; } } if (!$selected) { throw new Exception(pht("No application '%s'!", $class_name)); } return $selected; } final public static function getAllApplications() { static $applications; if ($applications === null) { $apps = id(new PhutilClassMapQuery()) ->setAncestorClass(__CLASS__) ->setSortMethod('getApplicationOrder') ->execute(); // Reorder the applications into "application order". Notably, this // ensures their event handlers register in application order. $apps = mgroup($apps, 'getApplicationGroup'); $group_order = array_keys(self::getApplicationGroups()); $apps = array_select_keys($apps, $group_order) + $apps; $apps = array_mergev($apps); $applications = $apps; } return $applications; } final public static function getAllInstalledApplications() { $all_applications = self::getAllApplications(); $apps = array(); foreach ($all_applications as $app) { if (!$app->isInstalled()) { continue; } $apps[] = $app; } return $apps; } /** * Determine if an application is installed, by application class name. * * To check if an application is installed //and// available to a particular * viewer, user @{method:isClassInstalledForViewer}. * * @param string Application class name. * @return bool True if the class is installed. * @task meta */ final public static function isClassInstalled($class) { return self::getByClass($class)->isInstalled(); } /** * Determine if an application is installed and available to a viewer, by * application class name. * * To check if an application is installed at all, use * @{method:isClassInstalled}. * * @param string Application class name. * @param PhabricatorUser Viewing user. * @return bool True if the class is installed for the viewer. * @task meta */ final public static function isClassInstalledForViewer( $class, PhabricatorUser $viewer) { if ($viewer->isOmnipotent()) { return true; } $cache = PhabricatorCaches::getRequestCache(); $viewer_fragment = $viewer->getCacheFragment(); $key = 'app.'.$class.'.installed.'.$viewer_fragment; $result = $cache->getKey($key); if ($result === null) { if (!self::isClassInstalled($class)) { $result = false; } else { $application = self::getByClass($class); if (!$application->canUninstall()) { // If the application can not be uninstalled, always allow viewers // to see it. In particular, this allows logged-out viewers to see // Settings and load global default settings even if the install // does not allow public viewers. $result = true; } else { $result = PhabricatorPolicyFilter::hasCapability( $viewer, self::getByClass($class), PhabricatorPolicyCapability::CAN_VIEW); } } $cache->setKey($key, $result); } return $result; } /* -( PhabricatorPolicyInterface )----------------------------------------- */ public function getCapabilities() { return array_merge( array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, ), array_keys($this->getCustomCapabilities())); } public function getPolicy($capability) { $default = $this->getCustomPolicySetting($capability); if ($default) { return $default; } switch ($capability) { case PhabricatorPolicyCapability::CAN_VIEW: return PhabricatorPolicies::getMostOpenPolicy(); case PhabricatorPolicyCapability::CAN_EDIT: return PhabricatorPolicies::POLICY_ADMIN; default: $spec = $this->getCustomCapabilitySpecification($capability); return idx($spec, 'default', PhabricatorPolicies::POLICY_USER); } } public function hasAutomaticCapability($capability, PhabricatorUser $viewer) { return false; } /* -( Policies )----------------------------------------------------------- */ protected function getCustomCapabilities() { return array(); } - final private function getCustomPolicySetting($capability) { + private function getCustomPolicySetting($capability) { if (!$this->isCapabilityEditable($capability)) { return null; } $policy_locked = PhabricatorEnv::getEnvConfig('policy.locked'); if (isset($policy_locked[$capability])) { return $policy_locked[$capability]; } $config = PhabricatorEnv::getEnvConfig('phabricator.application-settings'); $app = idx($config, $this->getPHID()); if (!$app) { return null; } $policy = idx($app, 'policy'); if (!$policy) { return null; } return idx($policy, $capability); } - final private function getCustomCapabilitySpecification($capability) { + private function getCustomCapabilitySpecification($capability) { $custom = $this->getCustomCapabilities(); if (!isset($custom[$capability])) { throw new Exception(pht("Unknown capability '%s'!", $capability)); } return $custom[$capability]; } final public function getCapabilityLabel($capability) { switch ($capability) { case PhabricatorPolicyCapability::CAN_VIEW: return pht('Can Use Application'); case PhabricatorPolicyCapability::CAN_EDIT: return pht('Can Configure Application'); } $capobj = PhabricatorPolicyCapability::getCapabilityByKey($capability); if ($capobj) { return $capobj->getCapabilityName(); } return null; } final public function isCapabilityEditable($capability) { switch ($capability) { case PhabricatorPolicyCapability::CAN_VIEW: return $this->canUninstall(); case PhabricatorPolicyCapability::CAN_EDIT: return true; default: $spec = $this->getCustomCapabilitySpecification($capability); return idx($spec, 'edit', true); } } final public function getCapabilityCaption($capability) { switch ($capability) { case PhabricatorPolicyCapability::CAN_VIEW: if (!$this->canUninstall()) { return pht( 'This application is required for Phabricator to operate, so all '. 'users must have access to it.'); } else { return null; } case PhabricatorPolicyCapability::CAN_EDIT: return null; default: $spec = $this->getCustomCapabilitySpecification($capability); return idx($spec, 'caption'); } } final public function getCapabilityTemplatePHIDType($capability) { switch ($capability) { case PhabricatorPolicyCapability::CAN_VIEW: case PhabricatorPolicyCapability::CAN_EDIT: return null; } $spec = $this->getCustomCapabilitySpecification($capability); return idx($spec, 'template'); } final public function getDefaultObjectTypePolicyMap() { $map = array(); foreach ($this->getCustomCapabilities() as $capability => $spec) { if (empty($spec['template'])) { continue; } if (empty($spec['capability'])) { continue; } $default = $this->getPolicy($capability); $map[$spec['template']][$spec['capability']] = $default; } return $map; } public function getApplicationSearchDocumentTypes() { return array(); } protected function getEditRoutePattern($base = null) { return $base.'(?:'. '(?P[0-9]\d*)/)?'. '(?:'. '(?:'. '(?Pparameters|nodefault|nocreate|nomanage|comment)/'. '|'. '(?:form/(?P[^/]+)/)?(?:page/(?P[^/]+)/)?'. ')'. ')?'; } protected function getBulkRoutePattern($base = null) { return $base.'(?:query/(?P[^/]+)/)?'; } protected function getQueryRoutePattern($base = null) { return $base.'(?:query/(?P[^/]+)/(?:(?P[^/]+)/)?)?'; } protected function getProfileMenuRouting($controller) { $edit_route = $this->getEditRoutePattern(); $mode_route = '(?Pglobal|custom)/'; return array( '(?Pview)/(?P[^/]+)/' => $controller, '(?Phide)/(?P[^/]+)/' => $controller, '(?Pdefault)/(?P[^/]+)/' => $controller, '(?Pconfigure)/' => $controller, '(?Pconfigure)/'.$mode_route => $controller, '(?Preorder)/'.$mode_route => $controller, '(?Pedit)/'.$edit_route => $controller, '(?Pnew)/'.$mode_route.'(?[^/]+)/'.$edit_route => $controller, '(?Pbuiltin)/(?[^/]+)/'.$edit_route => $controller, ); } /* -( PhabricatorApplicationTransactionInterface )------------------------- */ public function getApplicationTransactionEditor() { return new PhabricatorApplicationEditor(); } public function getApplicationTransactionTemplate() { return new PhabricatorApplicationApplicationTransaction(); } } diff --git a/src/applications/differential/storage/DifferentialHunk.php b/src/applications/differential/storage/DifferentialHunk.php index 6bfb38b789..f7decf69ae 100644 --- a/src/applications/differential/storage/DifferentialHunk.php +++ b/src/applications/differential/storage/DifferentialHunk.php @@ -1,485 +1,485 @@ array( 'data' => true, ), self::CONFIG_COLUMN_SCHEMA => array( 'dataType' => 'bytes4', 'dataEncoding' => 'text16?', 'dataFormat' => 'bytes4', 'oldOffset' => 'uint32', 'oldLen' => 'uint32', 'newOffset' => 'uint32', 'newLen' => 'uint32', ), self::CONFIG_KEY_SCHEMA => array( 'key_changeset' => array( 'columns' => array('changesetID'), ), 'key_created' => array( 'columns' => array('dateCreated'), ), ), ) + parent::getConfiguration(); } public function getAddedLines() { return $this->makeContent($include = '+'); } public function getRemovedLines() { return $this->makeContent($include = '-'); } public function makeNewFile() { return implode('', $this->makeContent($include = ' +')); } public function makeOldFile() { return implode('', $this->makeContent($include = ' -')); } public function makeChanges() { return implode('', $this->makeContent($include = '-+')); } public function getStructuredOldFile() { return $this->getStructuredFile('-'); } public function getStructuredNewFile() { return $this->getStructuredFile('+'); } private function getStructuredFile($kind) { if ($kind !== '+' && $kind !== '-') { throw new Exception( pht( 'Structured file kind should be "+" or "-", got "%s".', $kind)); } if (!isset($this->structuredFiles[$kind])) { if ($kind == '+') { $number = $this->newOffset; } else { $number = $this->oldOffset; } $lines = $this->getStructuredLines(); // NOTE: We keep the "\ No newline at end of file" line if it appears // after a line which is not excluded. For example, if we're constructing // the "+" side of the diff, we want to ignore this one since it's // relevant only to the "-" side of the diff: // // - x // \ No newline at end of file // + x // // ...but we want to keep this one: // // - x // + x // \ No newline at end of file $file = array(); $keep = true; foreach ($lines as $line) { switch ($line['type']) { case ' ': case $kind: $file[$number++] = $line; $keep = true; break; case '\\': if ($keep) { // Strip the actual newline off the line's text. $text = $file[$number - 1]['text']; $text = rtrim($text, "\r\n"); $file[$number - 1]['text'] = $text; $file[$number++] = $line; $keep = false; } break; default: $keep = false; break; } } $this->structuredFiles[$kind] = $file; } return $this->structuredFiles[$kind]; } public function getSplitLines() { if ($this->splitLines === null) { $this->splitLines = phutil_split_lines($this->getChanges()); } return $this->splitLines; } public function getStructuredLines() { if ($this->structuredLines === null) { $lines = $this->getSplitLines(); $structured = array(); foreach ($lines as $line) { if (empty($line[0])) { // TODO: Can we just get rid of this? continue; } $structured[] = array( 'type' => $line[0], 'text' => substr($line, 1), ); } $this->structuredLines = $structured; } return $this->structuredLines; } public function getContentWithMask($mask) { $include = array(); if (($mask & self::FLAG_LINES_ADDED)) { $include[] = '+'; } if (($mask & self::FLAG_LINES_REMOVED)) { $include[] = '-'; } if (($mask & self::FLAG_LINES_STABLE)) { $include[] = ' '; } $include = implode('', $include); return implode('', $this->makeContent($include)); } - final private function makeContent($include) { + private function makeContent($include) { $lines = $this->getSplitLines(); $results = array(); $include_map = array(); for ($ii = 0; $ii < strlen($include); $ii++) { $include_map[$include[$ii]] = true; } if (isset($include_map['+'])) { $n = $this->newOffset; } else { $n = $this->oldOffset; } $use_next_newline = false; foreach ($lines as $line) { if (!isset($line[0])) { continue; } if ($line[0] == '\\') { if ($use_next_newline) { $results[last_key($results)] = rtrim(end($results), "\n"); } } else if (empty($include_map[$line[0]])) { $use_next_newline = false; } else { $use_next_newline = true; $results[$n] = substr($line, 1); } if ($line[0] == ' ' || isset($include_map[$line[0]])) { $n++; } } return $results; } public function getChangeset() { return $this->assertAttached($this->changeset); } public function attachChangeset(DifferentialChangeset $changeset) { $this->changeset = $changeset; return $this; } /* -( Storage )------------------------------------------------------------ */ public function setChanges($text) { $this->rawData = $text; $this->dataEncoding = $this->detectEncodingForStorage($text); $this->dataType = self::DATATYPE_TEXT; list($format, $data) = $this->formatDataForStorage($text); $this->dataFormat = $format; $this->data = $data; return $this; } public function getChanges() { return $this->getUTF8StringFromStorage( $this->getRawData(), nonempty($this->forcedEncoding, $this->getDataEncoding())); } public function forceEncoding($encoding) { $this->forcedEncoding = $encoding; return $this; } private function formatDataForStorage($data) { $deflated = PhabricatorCaches::maybeDeflateData($data); if ($deflated !== null) { return array(self::DATAFORMAT_DEFLATED, $deflated); } return array(self::DATAFORMAT_RAW, $data); } public function getAutomaticDataFormat() { // If the hunk is already stored deflated, just keep it deflated. This is // mostly a performance improvement for "bin/differential migrate-hunk" so // that we don't have to recompress all the stored hunks when looking for // stray uncompressed hunks. if ($this->dataFormat === self::DATAFORMAT_DEFLATED) { return self::DATAFORMAT_DEFLATED; } list($format) = $this->formatDataForStorage($this->getRawData()); return $format; } public function saveAsText() { $old_type = $this->getDataType(); $old_data = $this->getData(); $raw_data = $this->getRawData(); $this->setDataType(self::DATATYPE_TEXT); list($format, $data) = $this->formatDataForStorage($raw_data); $this->setDataFormat($format); $this->setData($data); $result = $this->save(); $this->destroyData($old_type, $old_data); return $result; } public function saveAsFile() { $old_type = $this->getDataType(); $old_data = $this->getData(); $raw_data = $this->getRawData(); list($format, $data) = $this->formatDataForStorage($raw_data); $this->setDataFormat($format); $file = PhabricatorFile::newFromFileData( $data, array( 'name' => 'differential-hunk', 'mime-type' => 'application/octet-stream', 'viewPolicy' => PhabricatorPolicies::POLICY_NOONE, )); $this->setDataType(self::DATATYPE_FILE); $this->setData($file->getPHID()); // NOTE: Because hunks don't have a PHID and we just load hunk data with // the omnipotent viewer, we do not need to attach the file to anything. $result = $this->save(); $this->destroyData($old_type, $old_data); return $result; } private function getRawData() { if ($this->rawData === null) { $type = $this->getDataType(); $data = $this->getData(); switch ($type) { case self::DATATYPE_TEXT: // In this storage type, the changes are stored on the object. $data = $data; break; case self::DATATYPE_FILE: $data = $this->loadFileData(); break; default: throw new Exception( pht('Hunk has unsupported data type "%s"!', $type)); } $format = $this->getDataFormat(); switch ($format) { case self::DATAFORMAT_RAW: // In this format, the changes are stored as-is. $data = $data; break; case self::DATAFORMAT_DEFLATED: $data = PhabricatorCaches::inflateData($data); break; default: throw new Exception( pht('Hunk has unsupported data encoding "%s"!', $type)); } $this->rawData = $data; } return $this->rawData; } private function loadFileData() { if ($this->fileData === null) { $type = $this->getDataType(); if ($type !== self::DATATYPE_FILE) { throw new Exception( pht( 'Unable to load file data for hunk with wrong data type ("%s").', $type)); } $file_phid = $this->getData(); $file = $this->loadRawFile($file_phid); $data = $file->loadFileData(); $this->fileData = $data; } return $this->fileData; } private function loadRawFile($file_phid) { $viewer = PhabricatorUser::getOmnipotentUser(); $files = id(new PhabricatorFileQuery()) ->setViewer($viewer) ->withPHIDs(array($file_phid)) ->execute(); if (!$files) { throw new Exception( pht( 'Failed to load file ("%s") with hunk data.', $file_phid)); } $file = head($files); return $file; } private function destroyData( $type, $data, PhabricatorDestructionEngine $engine = null) { if (!$engine) { $engine = new PhabricatorDestructionEngine(); } switch ($type) { case self::DATATYPE_FILE: $file = $this->loadRawFile($data); $engine->destroyObject($file); break; } } /* -( PhabricatorPolicyInterface )----------------------------------------- */ public function getCapabilities() { return array( PhabricatorPolicyCapability::CAN_VIEW, ); } public function getPolicy($capability) { return $this->getChangeset()->getPolicy($capability); } public function hasAutomaticCapability($capability, PhabricatorUser $viewer) { return $this->getChangeset()->hasAutomaticCapability($capability, $viewer); } /* -( PhabricatorDestructibleInterface )----------------------------------- */ public function destroyObjectPermanently( PhabricatorDestructionEngine $engine) { $type = $this->getDataType(); $data = $this->getData(); $this->destroyData($type, $data, $engine); $this->delete(); } } diff --git a/src/applications/diffusion/request/DiffusionRequest.php b/src/applications/diffusion/request/DiffusionRequest.php index d866874f5d..49ea305908 100644 --- a/src/applications/diffusion/request/DiffusionRequest.php +++ b/src/applications/diffusion/request/DiffusionRequest.php @@ -1,698 +1,698 @@ getRepository()->supportsRefs(); } abstract protected function isStableCommit($symbol); protected function didInitialize() { return null; } /* -( Creating Requests )-------------------------------------------------- */ /** * Create a new synthetic request from a parameter dictionary. If you need * a @{class:DiffusionRequest} object in order to issue a DiffusionQuery, you * can use this method to build one. * * Parameters are: * * - `repository` Repository object or identifier. * - `user` Viewing user. Required if `repository` is an identifier. * - `branch` Optional, branch name. * - `path` Optional, file path. * - `commit` Optional, commit identifier. * - `line` Optional, line range. * * @param map See documentation. * @return DiffusionRequest New request object. * @task new */ final public static function newFromDictionary(array $data) { $repository_key = 'repository'; $identifier_key = 'callsign'; $viewer_key = 'user'; $repository = idx($data, $repository_key); $identifier = idx($data, $identifier_key); $have_repository = ($repository !== null); $have_identifier = ($identifier !== null); if ($have_repository && $have_identifier) { throw new Exception( pht( 'Specify "%s" or "%s", but not both.', $repository_key, $identifier_key)); } if (!$have_repository && !$have_identifier) { throw new Exception( pht( 'One of "%s" and "%s" is required.', $repository_key, $identifier_key)); } if ($have_repository) { if (!($repository instanceof PhabricatorRepository)) { if (empty($data[$viewer_key])) { throw new Exception( pht( 'Parameter "%s" is required if "%s" is provided.', $viewer_key, $identifier_key)); } $identifier = $repository; $repository = null; } } if ($identifier !== null) { $object = self::newFromIdentifier( $identifier, $data[$viewer_key], idx($data, 'edit')); } else { $object = self::newFromRepository($repository); } if (!$object) { return null; } $object->initializeFromDictionary($data); return $object; } /** * Internal. * * @task new */ - final private function __construct() { + private function __construct() { // } /** * Internal. Use @{method:newFromDictionary}, not this method. * * @param string Repository identifier. * @param PhabricatorUser Viewing user. * @return DiffusionRequest New request object. * @task new */ - final private static function newFromIdentifier( + private static function newFromIdentifier( $identifier, PhabricatorUser $viewer, $need_edit = false) { $query = id(new PhabricatorRepositoryQuery()) ->setViewer($viewer) ->withIdentifiers(array($identifier)) ->needProfileImage(true) ->needURIs(true); if ($need_edit) { $query->requireCapabilities( array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, )); } $repository = $query->executeOne(); if (!$repository) { return null; } return self::newFromRepository($repository); } /** * Internal. Use @{method:newFromDictionary}, not this method. * * @param PhabricatorRepository Repository object. * @return DiffusionRequest New request object. * @task new */ - final private static function newFromRepository( + private static function newFromRepository( PhabricatorRepository $repository) { $map = array( PhabricatorRepositoryType::REPOSITORY_TYPE_GIT => 'DiffusionGitRequest', PhabricatorRepositoryType::REPOSITORY_TYPE_SVN => 'DiffusionSvnRequest', PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL => 'DiffusionMercurialRequest', ); $class = idx($map, $repository->getVersionControlSystem()); if (!$class) { throw new Exception(pht('Unknown version control system!')); } $object = new $class(); $object->repository = $repository; return $object; } /** * Internal. Use @{method:newFromDictionary}, not this method. * * @param map Map of parsed data. * @return void * @task new */ - final private function initializeFromDictionary(array $data) { + private function initializeFromDictionary(array $data) { $blob = idx($data, 'blob'); if (strlen($blob)) { $blob = self::parseRequestBlob($blob, $this->supportsBranches()); $data = $blob + $data; } $this->path = idx($data, 'path'); $this->line = idx($data, 'line'); $this->initFromConduit = idx($data, 'initFromConduit', true); $this->lint = idx($data, 'lint'); $this->symbolicCommit = idx($data, 'commit'); if ($this->supportsBranches()) { $this->branch = idx($data, 'branch'); } if (!$this->getUser()) { $user = idx($data, 'user'); if (!$user) { throw new Exception( pht( 'You must provide a %s in the dictionary!', 'PhabricatorUser')); } $this->setUser($user); } $this->didInitialize(); } final public function setUser(PhabricatorUser $user) { $this->user = $user; return $this; } final public function getUser() { return $this->user; } public function getRepository() { return $this->repository; } public function setPath($path) { $this->path = $path; return $this; } public function getPath() { return $this->path; } public function getLine() { return $this->line; } public function getCommit() { // TODO: Probably remove all of this. if ($this->getSymbolicCommit() !== null) { return $this->getSymbolicCommit(); } return $this->getStableCommit(); } /** * Get the symbolic commit associated with this request. * * A symbolic commit may be a commit hash, an abbreviated commit hash, a * branch name, a tag name, or an expression like "HEAD^^^". The symbolic * commit may also be absent. * * This method always returns the symbol present in the original request, * in unmodified form. * * See also @{method:getStableCommit}. * * @return string|null Symbolic commit, if one was present in the request. */ public function getSymbolicCommit() { return $this->symbolicCommit; } /** * Modify the request to move the symbolic commit elsewhere. * * @param string New symbolic commit. * @return this */ public function updateSymbolicCommit($symbol) { $this->symbolicCommit = $symbol; $this->symbolicType = null; $this->stableCommit = null; return $this; } /** * Get the ref type (`commit` or `tag`) of the location associated with this * request. * * If a symbolic commit is present in the request, this method identifies * the type of the symbol. Otherwise, it identifies the type of symbol of * the location the request is implicitly associated with. This will probably * always be `commit`. * * @return string Symbolic commit type (`commit` or `tag`). */ public function getSymbolicType() { if ($this->symbolicType === null) { // As a side effect, this resolves the symbolic type. $this->getStableCommit(); } return $this->symbolicType; } /** * Retrieve the stable, permanent commit name identifying the repository * location associated with this request. * * This returns a non-symbolic identifier for the current commit: in Git and * Mercurial, a 40-character SHA1; in SVN, a revision number. * * See also @{method:getSymbolicCommit}. * * @return string Stable commit name, like a git hash or SVN revision. Not * a symbolic commit reference. */ public function getStableCommit() { if (!$this->stableCommit) { if ($this->isStableCommit($this->symbolicCommit)) { $this->stableCommit = $this->symbolicCommit; $this->symbolicType = 'commit'; } else { $this->queryStableCommit(); } } return $this->stableCommit; } public function getBranch() { return $this->branch; } public function getLint() { return $this->lint; } protected function getArcanistBranch() { return $this->getBranch(); } public function loadBranch() { // TODO: Get rid of this and do real Queries on real objects. if ($this->branchObject === false) { $this->branchObject = PhabricatorRepositoryBranch::loadBranch( $this->getRepository()->getID(), $this->getArcanistBranch()); } return $this->branchObject; } public function loadCoverage() { // TODO: This should also die. $branch = $this->loadBranch(); if (!$branch) { return; } $path = $this->getPath(); $path_map = id(new DiffusionPathIDQuery(array($path)))->loadPathIDs(); $coverage_row = queryfx_one( id(new PhabricatorRepository())->establishConnection('r'), 'SELECT * FROM %T WHERE branchID = %d AND pathID = %d ORDER BY commitID DESC LIMIT 1', 'repository_coverage', $branch->getID(), $path_map[$path]); if (!$coverage_row) { return null; } return idx($coverage_row, 'coverage'); } public function loadCommit() { if (empty($this->repositoryCommit)) { $repository = $this->getRepository(); $commit = id(new DiffusionCommitQuery()) ->setViewer($this->getUser()) ->withRepository($repository) ->withIdentifiers(array($this->getStableCommit())) ->executeOne(); if ($commit) { $commit->attachRepository($repository); } $this->repositoryCommit = $commit; } return $this->repositoryCommit; } public function loadCommitData() { if (empty($this->repositoryCommitData)) { $commit = $this->loadCommit(); $data = id(new PhabricatorRepositoryCommitData())->loadOneWhere( 'commitID = %d', $commit->getID()); if (!$data) { $data = new PhabricatorRepositoryCommitData(); $data->setCommitMessage( pht('(This commit has not been fully parsed yet.)')); } $this->repositoryCommitData = $data; } return $this->repositoryCommitData; } /* -( Managing Diffusion URIs )-------------------------------------------- */ public function generateURI(array $params) { if (empty($params['stable'])) { $default_commit = $this->getSymbolicCommit(); } else { $default_commit = $this->getStableCommit(); } $defaults = array( 'path' => $this->getPath(), 'branch' => $this->getBranch(), 'commit' => $default_commit, 'lint' => idx($params, 'lint', $this->getLint()), ); foreach ($defaults as $key => $val) { if (!isset($params[$key])) { // Overwrite NULL. $params[$key] = $val; } } return $this->getRepository()->generateURI($params); } /** * Internal. Public only for unit tests. * * Parse the request URI into components. * * @param string URI blob. * @param bool True if this VCS supports branches. * @return map Parsed URI. * * @task uri */ public static function parseRequestBlob($blob, $supports_branches) { $result = array( 'branch' => null, 'path' => null, 'commit' => null, 'line' => null, ); $matches = null; if ($supports_branches) { // Consume the front part of the URI, up to the first "/". This is the // path-component encoded branch name. if (preg_match('@^([^/]+)/@', $blob, $matches)) { $result['branch'] = phutil_unescape_uri_path_component($matches[1]); $blob = substr($blob, strlen($matches[1]) + 1); } } // Consume the back part of the URI, up to the first "$". Use a negative // lookbehind to prevent matching '$$'. We double the '$' symbol when // encoding so that files with names like "money/$100" will survive. $pattern = '@(?:(?:^|[^$])(?:[$][$])*)[$]([\d,-]+)$@'; if (preg_match($pattern, $blob, $matches)) { $result['line'] = $matches[1]; $blob = substr($blob, 0, -(strlen($matches[1]) + 1)); } // We've consumed the line number if it exists, so unescape "$" in the // rest of the string. $blob = str_replace('$$', '$', $blob); // Consume the commit name, stopping on ';;'. We allow any character to // appear in commits names, as they can sometimes be symbolic names (like // tag names or refs). if (preg_match('@(?:(?:^|[^;])(?:;;)*);([^;].*)$@', $blob, $matches)) { $result['commit'] = $matches[1]; $blob = substr($blob, 0, -(strlen($matches[1]) + 1)); } // We've consumed the commit if it exists, so unescape ";" in the rest // of the string. $blob = str_replace(';;', ';', $blob); if (strlen($blob)) { $result['path'] = $blob; } $parts = explode('/', $result['path']); foreach ($parts as $part) { // Prevent any hyjinx since we're ultimately shipping this to the // filesystem under a lot of workflows. if ($part == '..') { throw new Exception(pht('Invalid path URI.')); } } return $result; } /** * Check that the working copy of the repository is present and readable. * * @param string Path to the working copy. */ protected function validateWorkingCopy($path) { if (!is_readable(dirname($path))) { $this->raisePermissionException(); } if (!Filesystem::pathExists($path)) { $this->raiseCloneException(); } } protected function raisePermissionException() { $host = php_uname('n'); throw new DiffusionSetupException( pht( 'The clone of this repository ("%s") on the local machine ("%s") '. 'could not be read. Ensure that the repository is in a '. 'location where the web server has read permissions.', $this->getRepository()->getDisplayName(), $host)); } protected function raiseCloneException() { $host = php_uname('n'); throw new DiffusionSetupException( pht( 'The working copy for this repository ("%s") has not been cloned yet '. 'on this machine ("%s"). Make sure you havestarted the Phabricator '. 'daemons. If this problem persists for longer than a clone should '. 'take, check the daemon logs (in the Daemon Console) to see if there '. 'were errors cloning the repository. Consult the "Diffusion User '. 'Guide" in the documentation for help setting up repositories.', $this->getRepository()->getDisplayName(), $host)); } private function queryStableCommit() { $types = array(); if ($this->symbolicCommit) { $ref = $this->symbolicCommit; } else { if ($this->supportsBranches()) { $ref = $this->getBranch(); $types = array( PhabricatorRepositoryRefCursor::TYPE_BRANCH, ); } else { $ref = 'HEAD'; } } $results = $this->resolveRefs(array($ref), $types); $matches = idx($results, $ref, array()); if (!$matches) { $message = pht( 'Ref "%s" does not exist in this repository.', $ref); throw id(new DiffusionRefNotFoundException($message)) ->setRef($ref); } if (count($matches) > 1) { $match = $this->chooseBestRefMatch($ref, $matches); } else { $match = head($matches); } $this->stableCommit = $match['identifier']; $this->symbolicType = $match['type']; } public function getRefAlternatives() { // Make sure we've resolved the reference into a stable commit first. try { $this->getStableCommit(); } catch (DiffusionRefNotFoundException $ex) { // If we have a bad reference, just return the empty set of // alternatives. } return $this->refAlternatives; } private function chooseBestRefMatch($ref, array $results) { // First, filter out less-desirable matches. $candidates = array(); foreach ($results as $result) { // Exclude closed heads. if ($result['type'] == 'branch') { if (idx($result, 'closed')) { continue; } } $candidates[] = $result; } // If we filtered everything, undo the filtering. if (!$candidates) { $candidates = $results; } // TODO: Do a better job of selecting the best match? $match = head($candidates); // After choosing the best alternative, save all the alternatives so the // UI can show them to the user. if (count($candidates) > 1) { $this->refAlternatives = $candidates; } return $match; } public function resolveRefs(array $refs, array $types = array()) { // First, try to resolve refs from fast cache sources. $cached_query = id(new DiffusionCachedResolveRefsQuery()) ->setRepository($this->getRepository()) ->withRefs($refs); if ($types) { $cached_query->withTypes($types); } $cached_results = $cached_query->execute(); // Throw away all the refs we resolved. Hopefully, we'll throw away // everything here. foreach ($refs as $key => $ref) { if (isset($cached_results[$ref])) { unset($refs[$key]); } } // If we couldn't pull everything out of the cache, execute the underlying // VCS operation. if ($refs) { $vcs_results = DiffusionQuery::callConduitWithDiffusionRequest( $this->getUser(), $this, 'diffusion.resolverefs', array( 'types' => $types, 'refs' => $refs, )); } else { $vcs_results = array(); } return $vcs_results + $cached_results; } public function setIsClusterRequest($is_cluster_request) { $this->isClusterRequest = $is_cluster_request; return $this; } public function getIsClusterRequest() { return $this->isClusterRequest; } } diff --git a/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php b/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php index 0c5e642e30..437a7a1437 100644 --- a/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php +++ b/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php @@ -1,5418 +1,5418 @@ actingAsPHID = $acting_as_phid; return $this; } public function getActingAsPHID() { if ($this->actingAsPHID) { return $this->actingAsPHID; } return $this->getActor()->getPHID(); } /** * When the editor tries to apply transactions that have no effect, should * it raise an exception (default) or drop them and continue? * * Generally, you will set this flag for edits coming from "Edit" interfaces, * and leave it cleared for edits coming from "Comment" interfaces, so the * user will get a useful error if they try to submit a comment that does * nothing (e.g., empty comment with a status change that has already been * performed by another user). * * @param bool True to drop transactions without effect and continue. * @return this */ public function setContinueOnNoEffect($continue) { $this->continueOnNoEffect = $continue; return $this; } public function getContinueOnNoEffect() { return $this->continueOnNoEffect; } /** * When the editor tries to apply transactions which don't populate all of * an object's required fields, should it raise an exception (default) or * drop them and continue? * * For example, if a user adds a new required custom field (like "Severity") * to a task, all existing tasks won't have it populated. When users * manually edit existing tasks, it's usually desirable to have them provide * a severity. However, other operations (like batch editing just the * owner of a task) will fail by default. * * By setting this flag for edit operations which apply to specific fields * (like the priority, batch, and merge editors in Maniphest), these * operations can continue to function even if an object is outdated. * * @param bool True to continue when transactions don't completely satisfy * all required fields. * @return this */ public function setContinueOnMissingFields($continue_on_missing_fields) { $this->continueOnMissingFields = $continue_on_missing_fields; return $this; } public function getContinueOnMissingFields() { return $this->continueOnMissingFields; } /** * Not strictly necessary, but reply handlers ideally set this value to * make email threading work better. */ public function setParentMessageID($parent_message_id) { $this->parentMessageID = $parent_message_id; return $this; } public function getParentMessageID() { return $this->parentMessageID; } public function getIsNewObject() { return $this->isNewObject; } public function getMentionedPHIDs() { return $this->mentionedPHIDs; } public function setIsPreview($is_preview) { $this->isPreview = $is_preview; return $this; } public function getIsPreview() { return $this->isPreview; } public function setIsSilent($silent) { $this->silent = $silent; return $this; } public function getIsSilent() { return $this->silent; } public function getMustEncrypt() { return $this->mustEncrypt; } public function getHeraldRuleMonograms() { // Convert the stored "<123>, <456>" string into a list: "H123", "H456". $list = $this->heraldHeader; $list = preg_split('/[, ]+/', $list); foreach ($list as $key => $item) { $item = trim($item, '<>'); if (!is_numeric($item)) { unset($list[$key]); continue; } $list[$key] = 'H'.$item; } return $list; } public function setIsInverseEdgeEditor($is_inverse_edge_editor) { $this->isInverseEdgeEditor = $is_inverse_edge_editor; return $this; } public function getIsInverseEdgeEditor() { return $this->isInverseEdgeEditor; } public function setIsHeraldEditor($is_herald_editor) { $this->isHeraldEditor = $is_herald_editor; return $this; } public function getIsHeraldEditor() { return $this->isHeraldEditor; } public function addUnmentionablePHIDs(array $phids) { foreach ($phids as $phid) { $this->unmentionablePHIDMap[$phid] = true; } return $this; } private function getUnmentionablePHIDMap() { return $this->unmentionablePHIDMap; } protected function shouldEnableMentions( PhabricatorLiskDAO $object, array $xactions) { return true; } public function setApplicationEmail( PhabricatorMetaMTAApplicationEmail $email) { $this->applicationEmail = $email; return $this; } public function getApplicationEmail() { return $this->applicationEmail; } public function setRaiseWarnings($raise_warnings) { $this->raiseWarnings = $raise_warnings; return $this; } public function getRaiseWarnings() { return $this->raiseWarnings; } public function setShouldRequireMFA($should_require_mfa) { if ($this->hasRequiredMFA) { throw new Exception( pht( 'Call to setShouldRequireMFA() is too late: this Editor has already '. 'checked for MFA requirements.')); } $this->shouldRequireMFA = $should_require_mfa; return $this; } public function getShouldRequireMFA() { return $this->shouldRequireMFA; } public function getTransactionTypesForObject($object) { $old = $this->object; try { $this->object = $object; $result = $this->getTransactionTypes(); $this->object = $old; } catch (Exception $ex) { $this->object = $old; throw $ex; } return $result; } public function getTransactionTypes() { $types = array(); $types[] = PhabricatorTransactions::TYPE_CREATE; $types[] = PhabricatorTransactions::TYPE_HISTORY; if ($this->object instanceof PhabricatorEditEngineSubtypeInterface) { $types[] = PhabricatorTransactions::TYPE_SUBTYPE; } if ($this->object instanceof PhabricatorSubscribableInterface) { $types[] = PhabricatorTransactions::TYPE_SUBSCRIBERS; } if ($this->object instanceof PhabricatorCustomFieldInterface) { $types[] = PhabricatorTransactions::TYPE_CUSTOMFIELD; } if ($this->object instanceof PhabricatorTokenReceiverInterface) { $types[] = PhabricatorTransactions::TYPE_TOKEN; } if ($this->object instanceof PhabricatorProjectInterface || $this->object instanceof PhabricatorMentionableInterface) { $types[] = PhabricatorTransactions::TYPE_EDGE; } if ($this->object instanceof PhabricatorSpacesInterface) { $types[] = PhabricatorTransactions::TYPE_SPACE; } $types[] = PhabricatorTransactions::TYPE_MFA; $template = $this->object->getApplicationTransactionTemplate(); if ($template instanceof PhabricatorModularTransaction) { $xtypes = $template->newModularTransactionTypes(); foreach ($xtypes as $xtype) { $types[] = $xtype->getTransactionTypeConstant(); } } if ($template) { $comment = $template->getApplicationTransactionCommentObject(); if ($comment) { $types[] = PhabricatorTransactions::TYPE_COMMENT; } } return $types; } private function adjustTransactionValues( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { if ($xaction->shouldGenerateOldValue()) { $old = $this->getTransactionOldValue($object, $xaction); $xaction->setOldValue($old); } $new = $this->getTransactionNewValue($object, $xaction); $xaction->setNewValue($new); } private function getTransactionOldValue( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { $type = $xaction->getTransactionType(); $xtype = $this->getModularTransactionType($type); if ($xtype) { $xtype = clone $xtype; $xtype->setStorage($xaction); return $xtype->generateOldValue($object); } switch ($type) { case PhabricatorTransactions::TYPE_CREATE: case PhabricatorTransactions::TYPE_HISTORY: return null; case PhabricatorTransactions::TYPE_SUBTYPE: return $object->getEditEngineSubtype(); case PhabricatorTransactions::TYPE_MFA: return null; case PhabricatorTransactions::TYPE_SUBSCRIBERS: return array_values($this->subscribers); case PhabricatorTransactions::TYPE_VIEW_POLICY: if ($this->getIsNewObject()) { return null; } return $object->getViewPolicy(); case PhabricatorTransactions::TYPE_EDIT_POLICY: if ($this->getIsNewObject()) { return null; } return $object->getEditPolicy(); case PhabricatorTransactions::TYPE_JOIN_POLICY: if ($this->getIsNewObject()) { return null; } return $object->getJoinPolicy(); case PhabricatorTransactions::TYPE_SPACE: if ($this->getIsNewObject()) { return null; } $space_phid = $object->getSpacePHID(); if ($space_phid === null) { $default_space = PhabricatorSpacesNamespaceQuery::getDefaultSpace(); if ($default_space) { $space_phid = $default_space->getPHID(); } } return $space_phid; case PhabricatorTransactions::TYPE_EDGE: $edge_type = $xaction->getMetadataValue('edge:type'); if (!$edge_type) { throw new Exception( pht( "Edge transaction has no '%s'!", 'edge:type')); } // See T13082. If this is an inverse edit, the parent editor has // already populated the transaction values correctly. if ($this->getIsInverseEdgeEditor()) { return $xaction->getOldValue(); } $old_edges = array(); if ($object->getPHID()) { $edge_src = $object->getPHID(); $old_edges = id(new PhabricatorEdgeQuery()) ->withSourcePHIDs(array($edge_src)) ->withEdgeTypes(array($edge_type)) ->needEdgeData(true) ->execute(); $old_edges = $old_edges[$edge_src][$edge_type]; } return $old_edges; case PhabricatorTransactions::TYPE_CUSTOMFIELD: // NOTE: Custom fields have their old value pre-populated when they are // built by PhabricatorCustomFieldList. return $xaction->getOldValue(); case PhabricatorTransactions::TYPE_COMMENT: return null; default: return $this->getCustomTransactionOldValue($object, $xaction); } } private function getTransactionNewValue( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { $type = $xaction->getTransactionType(); $xtype = $this->getModularTransactionType($type); if ($xtype) { $xtype = clone $xtype; $xtype->setStorage($xaction); return $xtype->generateNewValue($object, $xaction->getNewValue()); } switch ($type) { case PhabricatorTransactions::TYPE_CREATE: return null; case PhabricatorTransactions::TYPE_SUBSCRIBERS: return $this->getPHIDTransactionNewValue($xaction); case PhabricatorTransactions::TYPE_VIEW_POLICY: case PhabricatorTransactions::TYPE_EDIT_POLICY: case PhabricatorTransactions::TYPE_JOIN_POLICY: case PhabricatorTransactions::TYPE_TOKEN: case PhabricatorTransactions::TYPE_INLINESTATE: case PhabricatorTransactions::TYPE_SUBTYPE: case PhabricatorTransactions::TYPE_HISTORY: return $xaction->getNewValue(); case PhabricatorTransactions::TYPE_MFA: return true; case PhabricatorTransactions::TYPE_SPACE: $space_phid = $xaction->getNewValue(); if (!strlen($space_phid)) { // If an install has no Spaces or the Spaces controls are not visible // to the viewer, we might end up with the empty string here instead // of a strict `null`, because some controller just used `getStr()` // to read the space PHID from the request. // Just make this work like callers might reasonably expect so we // don't need to handle this specially in every EditController. return $this->getActor()->getDefaultSpacePHID(); } else { return $space_phid; } case PhabricatorTransactions::TYPE_EDGE: // See T13082. If this is an inverse edit, the parent editor has // already populated appropriate transaction values. if ($this->getIsInverseEdgeEditor()) { return $xaction->getNewValue(); } $new_value = $this->getEdgeTransactionNewValue($xaction); $edge_type = $xaction->getMetadataValue('edge:type'); $type_project = PhabricatorProjectObjectHasProjectEdgeType::EDGECONST; if ($edge_type == $type_project) { $new_value = $this->applyProjectConflictRules($new_value); } return $new_value; case PhabricatorTransactions::TYPE_CUSTOMFIELD: $field = $this->getCustomFieldForTransaction($object, $xaction); return $field->getNewValueFromApplicationTransactions($xaction); case PhabricatorTransactions::TYPE_COMMENT: return null; default: return $this->getCustomTransactionNewValue($object, $xaction); } } protected function getCustomTransactionOldValue( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { throw new Exception(pht('Capability not supported!')); } protected function getCustomTransactionNewValue( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { throw new Exception(pht('Capability not supported!')); } protected function transactionHasEffect( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorTransactions::TYPE_CREATE: case PhabricatorTransactions::TYPE_HISTORY: return true; case PhabricatorTransactions::TYPE_CUSTOMFIELD: $field = $this->getCustomFieldForTransaction($object, $xaction); return $field->getApplicationTransactionHasEffect($xaction); case PhabricatorTransactions::TYPE_EDGE: // A straight value comparison here doesn't always get the right // result, because newly added edges aren't fully populated. Instead, // compare the changes in a more granular way. $old = $xaction->getOldValue(); $new = $xaction->getNewValue(); $old_dst = array_keys($old); $new_dst = array_keys($new); // NOTE: For now, we don't consider edge reordering to be a change. // We have very few order-dependent edges and effectively no order // oriented UI. This might change in the future. sort($old_dst); sort($new_dst); if ($old_dst !== $new_dst) { // We've added or removed edges, so this transaction definitely // has an effect. return true; } // We haven't added or removed edges, but we might have changed // edge data. foreach ($old as $key => $old_value) { $new_value = $new[$key]; if ($old_value['data'] !== $new_value['data']) { return true; } } return false; } $type = $xaction->getTransactionType(); $xtype = $this->getModularTransactionType($type); if ($xtype) { return $xtype->getTransactionHasEffect( $object, $xaction->getOldValue(), $xaction->getNewValue()); } if ($xaction->hasComment()) { return true; } return ($xaction->getOldValue() !== $xaction->getNewValue()); } protected function shouldApplyInitialEffects( PhabricatorLiskDAO $object, array $xactions) { return false; } protected function applyInitialEffects( PhabricatorLiskDAO $object, array $xactions) { throw new PhutilMethodNotImplementedException(); } private function applyInternalEffects( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { $type = $xaction->getTransactionType(); $xtype = $this->getModularTransactionType($type); if ($xtype) { $xtype = clone $xtype; $xtype->setStorage($xaction); return $xtype->applyInternalEffects($object, $xaction->getNewValue()); } switch ($type) { case PhabricatorTransactions::TYPE_CUSTOMFIELD: $field = $this->getCustomFieldForTransaction($object, $xaction); return $field->applyApplicationTransactionInternalEffects($xaction); case PhabricatorTransactions::TYPE_CREATE: case PhabricatorTransactions::TYPE_HISTORY: case PhabricatorTransactions::TYPE_SUBTYPE: case PhabricatorTransactions::TYPE_MFA: case PhabricatorTransactions::TYPE_TOKEN: case PhabricatorTransactions::TYPE_VIEW_POLICY: case PhabricatorTransactions::TYPE_EDIT_POLICY: case PhabricatorTransactions::TYPE_JOIN_POLICY: case PhabricatorTransactions::TYPE_SUBSCRIBERS: case PhabricatorTransactions::TYPE_INLINESTATE: case PhabricatorTransactions::TYPE_EDGE: case PhabricatorTransactions::TYPE_SPACE: case PhabricatorTransactions::TYPE_COMMENT: return $this->applyBuiltinInternalTransaction($object, $xaction); } return $this->applyCustomInternalTransaction($object, $xaction); } private function applyExternalEffects( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { $type = $xaction->getTransactionType(); $xtype = $this->getModularTransactionType($type); if ($xtype) { $xtype = clone $xtype; $xtype->setStorage($xaction); return $xtype->applyExternalEffects($object, $xaction->getNewValue()); } switch ($type) { case PhabricatorTransactions::TYPE_SUBSCRIBERS: $subeditor = id(new PhabricatorSubscriptionsEditor()) ->setObject($object) ->setActor($this->requireActor()); $old_map = array_fuse($xaction->getOldValue()); $new_map = array_fuse($xaction->getNewValue()); $subeditor->unsubscribe( array_keys( array_diff_key($old_map, $new_map))); $subeditor->subscribeExplicit( array_keys( array_diff_key($new_map, $old_map))); $subeditor->save(); // for the rest of these edits, subscribers should include those just // added as well as those just removed. $subscribers = array_unique(array_merge( $this->subscribers, $xaction->getOldValue(), $xaction->getNewValue())); $this->subscribers = $subscribers; return $this->applyBuiltinExternalTransaction($object, $xaction); case PhabricatorTransactions::TYPE_CUSTOMFIELD: $field = $this->getCustomFieldForTransaction($object, $xaction); return $field->applyApplicationTransactionExternalEffects($xaction); case PhabricatorTransactions::TYPE_CREATE: case PhabricatorTransactions::TYPE_HISTORY: case PhabricatorTransactions::TYPE_SUBTYPE: case PhabricatorTransactions::TYPE_MFA: case PhabricatorTransactions::TYPE_EDGE: case PhabricatorTransactions::TYPE_TOKEN: case PhabricatorTransactions::TYPE_VIEW_POLICY: case PhabricatorTransactions::TYPE_EDIT_POLICY: case PhabricatorTransactions::TYPE_JOIN_POLICY: case PhabricatorTransactions::TYPE_INLINESTATE: case PhabricatorTransactions::TYPE_SPACE: case PhabricatorTransactions::TYPE_COMMENT: return $this->applyBuiltinExternalTransaction($object, $xaction); } return $this->applyCustomExternalTransaction($object, $xaction); } protected function applyCustomInternalTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { $type = $xaction->getTransactionType(); throw new Exception( pht( "Transaction type '%s' is missing an internal apply implementation!", $type)); } protected function applyCustomExternalTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { $type = $xaction->getTransactionType(); throw new Exception( pht( "Transaction type '%s' is missing an external apply implementation!", $type)); } /** * @{class:PhabricatorTransactions} provides many built-in transactions * which should not require much - if any - code in specific applications. * * This method is a hook for the exceedingly-rare cases where you may need * to do **additional** work for built-in transactions. Developers should * extend this method, making sure to return the parent implementation * regardless of handling any transactions. * * See also @{method:applyBuiltinExternalTransaction}. */ protected function applyBuiltinInternalTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorTransactions::TYPE_VIEW_POLICY: $object->setViewPolicy($xaction->getNewValue()); break; case PhabricatorTransactions::TYPE_EDIT_POLICY: $object->setEditPolicy($xaction->getNewValue()); break; case PhabricatorTransactions::TYPE_JOIN_POLICY: $object->setJoinPolicy($xaction->getNewValue()); break; case PhabricatorTransactions::TYPE_SPACE: $object->setSpacePHID($xaction->getNewValue()); break; case PhabricatorTransactions::TYPE_SUBTYPE: $object->setEditEngineSubtype($xaction->getNewValue()); break; } } /** * See @{method::applyBuiltinInternalTransaction}. */ protected function applyBuiltinExternalTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorTransactions::TYPE_EDGE: if ($this->getIsInverseEdgeEditor()) { // If we're writing an inverse edge transaction, don't actually // do anything. The initiating editor on the other side of the // transaction will take care of the edge writes. break; } $old = $xaction->getOldValue(); $new = $xaction->getNewValue(); $src = $object->getPHID(); $const = $xaction->getMetadataValue('edge:type'); foreach ($new as $dst_phid => $edge) { $new[$dst_phid]['src'] = $src; } $editor = new PhabricatorEdgeEditor(); foreach ($old as $dst_phid => $edge) { if (!empty($new[$dst_phid])) { if ($old[$dst_phid]['data'] === $new[$dst_phid]['data']) { continue; } } $editor->removeEdge($src, $const, $dst_phid); } foreach ($new as $dst_phid => $edge) { if (!empty($old[$dst_phid])) { if ($old[$dst_phid]['data'] === $new[$dst_phid]['data']) { continue; } } $data = array( 'data' => $edge['data'], ); $editor->addEdge($src, $const, $dst_phid, $data); } $editor->save(); $this->updateWorkboardColumns($object, $const, $old, $new); break; case PhabricatorTransactions::TYPE_VIEW_POLICY: case PhabricatorTransactions::TYPE_SPACE: $this->scrambleFileSecrets($object); break; case PhabricatorTransactions::TYPE_HISTORY: $this->sendHistory = true; break; } } /** * Fill in a transaction's common values, like author and content source. */ protected function populateTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { $actor = $this->getActor(); // TODO: This needs to be more sophisticated once we have meta-policies. $xaction->setViewPolicy(PhabricatorPolicies::POLICY_PUBLIC); if ($actor->isOmnipotent()) { $xaction->setEditPolicy(PhabricatorPolicies::POLICY_NOONE); } else { $xaction->setEditPolicy($this->getActingAsPHID()); } // If the transaction already has an explicit author PHID, allow it to // stand. This is used by applications like Owners that hook into the // post-apply change pipeline. if (!$xaction->getAuthorPHID()) { $xaction->setAuthorPHID($this->getActingAsPHID()); } $xaction->setContentSource($this->getContentSource()); $xaction->attachViewer($actor); $xaction->attachObject($object); if ($object->getPHID()) { $xaction->setObjectPHID($object->getPHID()); } if ($this->getIsSilent()) { $xaction->setIsSilentTransaction(true); } return $xaction; } protected function didApplyInternalEffects( PhabricatorLiskDAO $object, array $xactions) { return $xactions; } protected function applyFinalEffects( PhabricatorLiskDAO $object, array $xactions) { return $xactions; } final protected function didCommitTransactions( PhabricatorLiskDAO $object, array $xactions) { foreach ($xactions as $xaction) { $type = $xaction->getTransactionType(); // See T13082. When we're writing edges that imply corresponding inverse // transactions, apply those inverse transactions now. We have to wait // until the object we're editing (with this editor) has committed its // transactions to do this. If we don't, the inverse editor may race, // build a mail before we actually commit this object, and render "alice // added an edge: Unknown Object". if ($type === PhabricatorTransactions::TYPE_EDGE) { // Don't do anything if we're already an inverse edge editor. if ($this->getIsInverseEdgeEditor()) { continue; } $edge_const = $xaction->getMetadataValue('edge:type'); $edge_type = PhabricatorEdgeType::getByConstant($edge_const); if ($edge_type->shouldWriteInverseTransactions()) { $this->applyInverseEdgeTransactions( $object, $xaction, $edge_type->getInverseEdgeConstant()); } continue; } $xtype = $this->getModularTransactionType($type); if (!$xtype) { continue; } $xtype = clone $xtype; $xtype->setStorage($xaction); $xtype->didCommitTransaction($object, $xaction->getNewValue()); } } public function setContentSource(PhabricatorContentSource $content_source) { $this->contentSource = $content_source; return $this; } public function setContentSourceFromRequest(AphrontRequest $request) { $this->setRequest($request); return $this->setContentSource( PhabricatorContentSource::newFromRequest($request)); } public function getContentSource() { return $this->contentSource; } public function setRequest(AphrontRequest $request) { $this->request = $request; return $this; } public function getRequest() { return $this->request; } public function setCancelURI($cancel_uri) { $this->cancelURI = $cancel_uri; return $this; } public function getCancelURI() { return $this->cancelURI; } protected function getTransactionGroupID() { if ($this->transactionGroupID === null) { $this->transactionGroupID = Filesystem::readRandomCharacters(32); } return $this->transactionGroupID; } final public function applyTransactions( PhabricatorLiskDAO $object, array $xactions) { $is_new = ($object->getID() === null); $this->isNewObject = $is_new; $is_preview = $this->getIsPreview(); $read_locking = false; $transaction_open = false; // If we're attempting to apply transactions, lock and reload the object // before we go anywhere. If we don't do this at the very beginning, we // may be looking at an older version of the object when we populate and // filter the transactions. See PHI1165 for an example. if (!$is_preview) { if (!$is_new) { $this->buildOldRecipientLists($object, $xactions); $object->openTransaction(); $transaction_open = true; $object->beginReadLocking(); $read_locking = true; $object->reload(); } } try { $this->object = $object; $this->xactions = $xactions; $this->validateEditParameters($object, $xactions); $xactions = $this->newMFATransactions($object, $xactions); $actor = $this->requireActor(); // NOTE: Some transaction expansion requires that the edited object be // attached. foreach ($xactions as $xaction) { $xaction->attachObject($object); $xaction->attachViewer($actor); } $xactions = $this->expandTransactions($object, $xactions); $xactions = $this->expandSupportTransactions($object, $xactions); $xactions = $this->combineTransactions($xactions); foreach ($xactions as $xaction) { $xaction = $this->populateTransaction($object, $xaction); } if (!$is_preview) { $errors = array(); $type_map = mgroup($xactions, 'getTransactionType'); foreach ($this->getTransactionTypes() as $type) { $type_xactions = idx($type_map, $type, array()); $errors[] = $this->validateTransaction( $object, $type, $type_xactions); } $errors[] = $this->validateAllTransactions($object, $xactions); $errors[] = $this->validateTransactionsWithExtensions( $object, $xactions); $errors = array_mergev($errors); $continue_on_missing = $this->getContinueOnMissingFields(); foreach ($errors as $key => $error) { if ($continue_on_missing && $error->getIsMissingFieldError()) { unset($errors[$key]); } } if ($errors) { throw new PhabricatorApplicationTransactionValidationException( $errors); } if ($this->raiseWarnings) { $warnings = array(); foreach ($xactions as $xaction) { if ($this->hasWarnings($object, $xaction)) { $warnings[] = $xaction; } } if ($warnings) { throw new PhabricatorApplicationTransactionWarningException( $warnings); } } } foreach ($xactions as $xaction) { $this->adjustTransactionValues($object, $xaction); } // Now that we've merged and combined transactions, check for required // capabilities. Note that we're doing this before filtering // transactions: if you try to apply an edit which you do not have // permission to apply, we want to give you a permissions error even // if the edit would have no effect. $this->applyCapabilityChecks($object, $xactions); $xactions = $this->filterTransactions($object, $xactions); if (!$is_preview) { $this->hasRequiredMFA = true; if ($this->getShouldRequireMFA()) { $this->requireMFA($object, $xactions); } if ($this->shouldApplyInitialEffects($object, $xactions)) { if (!$transaction_open) { $object->openTransaction(); $transaction_open = true; } } } if ($this->shouldApplyInitialEffects($object, $xactions)) { $this->applyInitialEffects($object, $xactions); } // TODO: Once everything is on EditEngine, just use getIsNewObject() to // figure this out instead. $mark_as_create = false; $create_type = PhabricatorTransactions::TYPE_CREATE; foreach ($xactions as $xaction) { if ($xaction->getTransactionType() == $create_type) { $mark_as_create = true; } } if ($mark_as_create) { foreach ($xactions as $xaction) { $xaction->setIsCreateTransaction(true); } } $xactions = $this->sortTransactions($xactions); $file_phids = $this->extractFilePHIDs($object, $xactions); if ($is_preview) { $this->loadHandles($xactions); return $xactions; } $comment_editor = id(new PhabricatorApplicationTransactionCommentEditor()) ->setActor($actor) ->setActingAsPHID($this->getActingAsPHID()) ->setContentSource($this->getContentSource()) ->setIsNewComment(true); if (!$transaction_open) { $object->openTransaction(); $transaction_open = true; } // We can technically test any object for CAN_INTERACT, but we can // run into some issues in doing so (for example, in project unit tests). // For now, only test for CAN_INTERACT if the object is explicitly a // lockable object. $was_locked = false; if ($object instanceof PhabricatorEditEngineLockableInterface) { $was_locked = !PhabricatorPolicyFilter::canInteract($actor, $object); } foreach ($xactions as $xaction) { $this->applyInternalEffects($object, $xaction); } $xactions = $this->didApplyInternalEffects($object, $xactions); try { $object->save(); } catch (AphrontDuplicateKeyQueryException $ex) { // This callback has an opportunity to throw a better exception, // so execution may end here. $this->didCatchDuplicateKeyException($object, $xactions, $ex); throw $ex; } $group_id = $this->getTransactionGroupID(); foreach ($xactions as $xaction) { if ($was_locked) { $is_override = $this->isLockOverrideTransaction($xaction); if ($is_override) { $xaction->setIsLockOverrideTransaction(true); } } $xaction->setObjectPHID($object->getPHID()); $xaction->setTransactionGroupID($group_id); if ($xaction->getComment()) { $xaction->setPHID($xaction->generatePHID()); $comment_editor->applyEdit($xaction, $xaction->getComment()); } else { // TODO: This is a transitional hack to let us migrate edge // transactions to a more efficient storage format. For now, we're // going to write a new slim format to the database but keep the old // bulky format on the objects so we don't have to upgrade all the // edit logic to the new format yet. See T13051. $edge_type = PhabricatorTransactions::TYPE_EDGE; if ($xaction->getTransactionType() == $edge_type) { $bulky_old = $xaction->getOldValue(); $bulky_new = $xaction->getNewValue(); $record = PhabricatorEdgeChangeRecord::newFromTransaction($xaction); $slim_old = $record->getModernOldEdgeTransactionData(); $slim_new = $record->getModernNewEdgeTransactionData(); $xaction->setOldValue($slim_old); $xaction->setNewValue($slim_new); $xaction->save(); $xaction->setOldValue($bulky_old); $xaction->setNewValue($bulky_new); } else { $xaction->save(); } } } if ($file_phids) { $this->attachFiles($object, $file_phids); } foreach ($xactions as $xaction) { $this->applyExternalEffects($object, $xaction); } $xactions = $this->applyFinalEffects($object, $xactions); if ($read_locking) { $object->endReadLocking(); $read_locking = false; } if ($transaction_open) { $object->saveTransaction(); $transaction_open = false; } $this->didCommitTransactions($object, $xactions); } catch (Exception $ex) { if ($read_locking) { $object->endReadLocking(); $read_locking = false; } if ($transaction_open) { $object->killTransaction(); $transaction_open = false; } throw $ex; } // If we need to perform cache engine updates, execute them now. id(new PhabricatorCacheEngine()) ->updateObject($object); // Now that we've completely applied the core transaction set, try to apply // Herald rules. Herald rules are allowed to either take direct actions on // the database (like writing flags), or take indirect actions (like saving // some targets for CC when we generate mail a little later), or return // transactions which we'll apply normally using another Editor. // First, check if *this* is a sub-editor which is itself applying Herald // rules: if it is, stop working and return so we don't descend into // madness. // Otherwise, we're not a Herald editor, so process Herald rules (possibly // using a Herald editor to apply resulting transactions) and then send out // mail, notifications, and feed updates about everything. if ($this->getIsHeraldEditor()) { // We are the Herald editor, so stop work here and return the updated // transactions. return $xactions; } else if ($this->getIsInverseEdgeEditor()) { // Do not run Herald if we're just recording that this object was // mentioned elsewhere. This tends to create Herald side effects which // feel arbitrary, and can really slow down edits which mention a large // number of other objects. See T13114. } else if ($this->shouldApplyHeraldRules($object, $xactions)) { // We are not the Herald editor, so try to apply Herald rules. $herald_xactions = $this->applyHeraldRules($object, $xactions); if ($herald_xactions) { $xscript_id = $this->getHeraldTranscript()->getID(); foreach ($herald_xactions as $herald_xaction) { // Don't set a transcript ID if this is a transaction from another // application or source, like Owners. if ($herald_xaction->getAuthorPHID()) { continue; } $herald_xaction->setMetadataValue('herald:transcriptID', $xscript_id); } // NOTE: We're acting as the omnipotent user because rules deal with // their own policy issues. We use a synthetic author PHID (the // Herald application) as the author of record, so that transactions // will render in a reasonable way ("Herald assigned this task ..."). $herald_actor = PhabricatorUser::getOmnipotentUser(); $herald_phid = id(new PhabricatorHeraldApplication())->getPHID(); // TODO: It would be nice to give transactions a more specific source // which points at the rule which generated them. You can figure this // out from transcripts, but it would be cleaner if you didn't have to. $herald_source = PhabricatorContentSource::newForSource( PhabricatorHeraldContentSource::SOURCECONST); $herald_editor = $this->newEditorCopy() ->setContinueOnNoEffect(true) ->setContinueOnMissingFields(true) ->setIsHeraldEditor(true) ->setActor($herald_actor) ->setActingAsPHID($herald_phid) ->setContentSource($herald_source); $herald_xactions = $herald_editor->applyTransactions( $object, $herald_xactions); // Merge the new transactions into the transaction list: we want to // send email and publish feed stories about them, too. $xactions = array_merge($xactions, $herald_xactions); } // If Herald did not generate transactions, we may still need to handle // "Send an Email" rules. $adapter = $this->getHeraldAdapter(); $this->heraldEmailPHIDs = $adapter->getEmailPHIDs(); $this->heraldForcedEmailPHIDs = $adapter->getForcedEmailPHIDs(); $this->webhookMap = $adapter->getWebhookMap(); } $xactions = $this->didApplyTransactions($object, $xactions); if ($object instanceof PhabricatorCustomFieldInterface) { // Maybe this makes more sense to move into the search index itself? For // now I'm putting it here since I think we might end up with things that // need it to be up to date once the next page loads, but if we don't go // there we could move it into search once search moves to the daemons. // It now happens in the search indexer as well, but the search indexer is // always daemonized, so the logic above still potentially holds. We could // possibly get rid of this. The major motivation for putting it in the // indexer was to enable reindexing to work. $fields = PhabricatorCustomField::getObjectFields( $object, PhabricatorCustomField::ROLE_APPLICATIONSEARCH); $fields->readFieldsFromStorage($object); $fields->rebuildIndexes($object); } $herald_xscript = $this->getHeraldTranscript(); if ($herald_xscript) { $herald_header = $herald_xscript->getXHeraldRulesHeader(); $herald_header = HeraldTranscript::saveXHeraldRulesHeader( $object->getPHID(), $herald_header); } else { $herald_header = HeraldTranscript::loadXHeraldRulesHeader( $object->getPHID()); } $this->heraldHeader = $herald_header; // See PHI1134. If we're a subeditor, we don't publish information about // the edit yet. Our parent editor still needs to finish applying // transactions and execute Herald, which may change the information we // publish. // For example, Herald actions may change the parent object's title or // visibility, or Herald may apply rules like "Must Encrypt" that affect // email. // Once the parent finishes work, it will queue its own publish step and // then queue publish steps for its children. $this->publishableObject = $object; $this->publishableTransactions = $xactions; if (!$this->parentEditor) { $this->queuePublishing(); } return $xactions; } - final private function queuePublishing() { + private function queuePublishing() { $object = $this->publishableObject; $xactions = $this->publishableTransactions; if (!$object) { throw new Exception( pht( 'Editor method "queuePublishing()" was called, but no publishable '. 'object is present. This Editor is not ready to publish.')); } // We're going to compute some of the data we'll use to publish these // transactions here, before queueing a worker. // // Primarily, this is more correct: we want to publish the object as it // exists right now. The worker may not execute for some time, and we want // to use the current To/CC list, not respect any changes which may occur // between now and when the worker executes. // // As a secondary benefit, this tends to reduce the amount of state that // Editors need to pass into workers. $object = $this->willPublish($object, $xactions); if (!$this->getIsSilent()) { if ($this->shouldSendMail($object, $xactions)) { $this->mailShouldSend = true; $this->mailToPHIDs = $this->getMailTo($object); $this->mailCCPHIDs = $this->getMailCC($object); $this->mailUnexpandablePHIDs = $this->newMailUnexpandablePHIDs($object); // Add any recipients who were previously on the notification list // but were removed by this change. $this->applyOldRecipientLists(); if ($object instanceof PhabricatorSubscribableInterface) { $this->mailMutedPHIDs = PhabricatorEdgeQuery::loadDestinationPHIDs( $object->getPHID(), PhabricatorMutedByEdgeType::EDGECONST); } else { $this->mailMutedPHIDs = array(); } $mail_xactions = $this->getTransactionsForMail($object, $xactions); $stamps = $this->newMailStamps($object, $xactions); foreach ($stamps as $stamp) { $this->mailStamps[] = $stamp->toDictionary(); } } if ($this->shouldPublishFeedStory($object, $xactions)) { $this->feedShouldPublish = true; $this->feedRelatedPHIDs = $this->getFeedRelatedPHIDs( $object, $xactions); $this->feedNotifyPHIDs = $this->getFeedNotifyPHIDs( $object, $xactions); } } PhabricatorWorker::scheduleTask( 'PhabricatorApplicationTransactionPublishWorker', array( 'objectPHID' => $object->getPHID(), 'actorPHID' => $this->getActingAsPHID(), 'xactionPHIDs' => mpull($xactions, 'getPHID'), 'state' => $this->getWorkerState(), ), array( 'objectPHID' => $object->getPHID(), 'priority' => PhabricatorWorker::PRIORITY_ALERTS, )); foreach ($this->subEditors as $sub_editor) { $sub_editor->queuePublishing(); } $this->flushTransactionQueue($object); } protected function didCatchDuplicateKeyException( PhabricatorLiskDAO $object, array $xactions, Exception $ex) { return; } public function publishTransactions( PhabricatorLiskDAO $object, array $xactions) { $this->object = $object; $this->xactions = $xactions; // Hook for edges or other properties that may need (re-)loading $object = $this->willPublish($object, $xactions); // The object might have changed, so reassign it. $this->object = $object; $messages = array(); if ($this->mailShouldSend) { $messages = $this->buildMail($object, $xactions); } if ($this->supportsSearch()) { PhabricatorSearchWorker::queueDocumentForIndexing( $object->getPHID(), array( 'transactionPHIDs' => mpull($xactions, 'getPHID'), )); } if ($this->feedShouldPublish) { $mailed = array(); foreach ($messages as $mail) { foreach ($mail->buildRecipientList() as $phid) { $mailed[$phid] = $phid; } } $this->publishFeedStory($object, $xactions, $mailed); } if ($this->sendHistory) { $history_mail = $this->buildHistoryMail($object); if ($history_mail) { $messages[] = $history_mail; } } foreach ($this->newAuxiliaryMail($object, $xactions) as $message) { $messages[] = $message; } // NOTE: This actually sends the mail. We do this last to reduce the chance // that we send some mail, hit an exception, then send the mail again when // retrying. foreach ($messages as $mail) { $mail->save(); } $this->queueWebhooks($object, $xactions); return $xactions; } protected function didApplyTransactions($object, array $xactions) { // Hook for subclasses. return $xactions; } private function loadHandles(array $xactions) { $phids = array(); foreach ($xactions as $key => $xaction) { $phids[$key] = $xaction->getRequiredHandlePHIDs(); } $handles = array(); $merged = array_mergev($phids); if ($merged) { $handles = id(new PhabricatorHandleQuery()) ->setViewer($this->requireActor()) ->withPHIDs($merged) ->execute(); } foreach ($xactions as $key => $xaction) { $xaction->setHandles(array_select_keys($handles, $phids[$key])); } } private function loadSubscribers(PhabricatorLiskDAO $object) { if ($object->getPHID() && ($object instanceof PhabricatorSubscribableInterface)) { $subs = PhabricatorSubscribersQuery::loadSubscribersForPHID( $object->getPHID()); $this->subscribers = array_fuse($subs); } else { $this->subscribers = array(); } } private function validateEditParameters( PhabricatorLiskDAO $object, array $xactions) { if (!$this->getContentSource()) { throw new PhutilInvalidStateException('setContentSource'); } // Do a bunch of sanity checks that the incoming transactions are fresh. // They should be unsaved and have only "transactionType" and "newValue" // set. $types = array_fill_keys($this->getTransactionTypes(), true); assert_instances_of($xactions, 'PhabricatorApplicationTransaction'); foreach ($xactions as $xaction) { if ($xaction->getPHID() || $xaction->getID()) { throw new PhabricatorApplicationTransactionStructureException( $xaction, pht('You can not apply transactions which already have IDs/PHIDs!')); } if ($xaction->getObjectPHID()) { throw new PhabricatorApplicationTransactionStructureException( $xaction, pht( 'You can not apply transactions which already have %s!', 'objectPHIDs')); } if ($xaction->getCommentPHID()) { throw new PhabricatorApplicationTransactionStructureException( $xaction, pht( 'You can not apply transactions which already have %s!', 'commentPHIDs')); } if ($xaction->getCommentVersion() !== 0) { throw new PhabricatorApplicationTransactionStructureException( $xaction, pht( 'You can not apply transactions which already have '. 'commentVersions!')); } $expect_value = !$xaction->shouldGenerateOldValue(); $has_value = $xaction->hasOldValue(); // See T13082. In the narrow case of applying inverse edge edits, we // expect the old value to be populated. if ($this->getIsInverseEdgeEditor()) { $expect_value = true; } if ($expect_value && !$has_value) { throw new PhabricatorApplicationTransactionStructureException( $xaction, pht( 'This transaction is supposed to have an %s set, but it does not!', 'oldValue')); } if ($has_value && !$expect_value) { throw new PhabricatorApplicationTransactionStructureException( $xaction, pht( 'This transaction should generate its %s automatically, '. 'but has already had one set!', 'oldValue')); } $type = $xaction->getTransactionType(); if (empty($types[$type])) { throw new PhabricatorApplicationTransactionStructureException( $xaction, pht( 'Transaction has type "%s", but that transaction type is not '. 'supported by this editor (%s).', $type, get_class($this))); } } } private function applyCapabilityChecks( PhabricatorLiskDAO $object, array $xactions) { assert_instances_of($xactions, 'PhabricatorApplicationTransaction'); $can_edit = PhabricatorPolicyCapability::CAN_EDIT; if ($this->getIsNewObject()) { // If we're creating a new object, we don't need any special capabilities // on the object. The actor has already made it through creation checks, // and objects which haven't been created yet often can not be // meaningfully tested for capabilities anyway. $required_capabilities = array(); } else { if (!$xactions && !$this->xactions) { // If we aren't doing anything, require CAN_EDIT to improve consistency. $required_capabilities = array($can_edit); } else { $required_capabilities = array(); foreach ($xactions as $xaction) { $type = $xaction->getTransactionType(); $xtype = $this->getModularTransactionType($type); if (!$xtype) { $capabilities = $this->getLegacyRequiredCapabilities($xaction); } else { $capabilities = $xtype->getRequiredCapabilities($object, $xaction); } // For convenience, we allow flexibility in the return types because // it's very unusual that a transaction actually requires multiple // capability checks. if ($capabilities === null) { $capabilities = array(); } else { $capabilities = (array)$capabilities; } foreach ($capabilities as $capability) { $required_capabilities[$capability] = $capability; } } } } $required_capabilities = array_fuse($required_capabilities); $actor = $this->getActor(); if ($required_capabilities) { id(new PhabricatorPolicyFilter()) ->setViewer($actor) ->requireCapabilities($required_capabilities) ->raisePolicyExceptions(true) ->apply(array($object)); } } private function getLegacyRequiredCapabilities( PhabricatorApplicationTransaction $xaction) { $type = $xaction->getTransactionType(); switch ($type) { case PhabricatorTransactions::TYPE_COMMENT: // TODO: Comments technically require CAN_INTERACT, but this is // currently somewhat special and handled through EditEngine. For now, // don't enforce it here. return null; case PhabricatorTransactions::TYPE_SUBSCRIBERS: // Anyone can subscribe to or unsubscribe from anything they can view, // with no other permissions. $old = array_fuse($xaction->getOldValue()); $new = array_fuse($xaction->getNewValue()); // To remove users other than yourself, you must be able to edit the // object. $rem = array_diff_key($old, $new); foreach ($rem as $phid) { if ($phid !== $this->getActingAsPHID()) { return PhabricatorPolicyCapability::CAN_EDIT; } } // To add users other than yourself, you must be able to interact. // This allows "@mentioning" users to work as long as you can comment // on objects. // If you can edit, we return that policy instead so that you can // override a soft lock and still make edits. // TODO: This is a little bit hacky. We really want to be able to say // "this requires either interact or edit", but there's currently no // way to specify this kind of requirement. $can_edit = PhabricatorPolicyFilter::hasCapability( $this->getActor(), $this->object, PhabricatorPolicyCapability::CAN_EDIT); $add = array_diff_key($new, $old); foreach ($add as $phid) { if ($phid !== $this->getActingAsPHID()) { if ($can_edit) { return PhabricatorPolicyCapability::CAN_EDIT; } else { return PhabricatorPolicyCapability::CAN_INTERACT; } } } return null; case PhabricatorTransactions::TYPE_TOKEN: // TODO: This technically requires CAN_INTERACT, like comments. return null; case PhabricatorTransactions::TYPE_HISTORY: // This is a special magic transaction which sends you history via // email and is only partially supported in the upstream. You don't // need any capabilities to apply it. return null; case PhabricatorTransactions::TYPE_MFA: // Signing a transaction group with MFA does not require permissions // on its own. return null; case PhabricatorTransactions::TYPE_EDGE: return $this->getLegacyRequiredEdgeCapabilities($xaction); default: // For other older (non-modular) transactions, always require exactly // CAN_EDIT. Transactions which do not need CAN_EDIT or need additional // capabilities must move to ModularTransactions. return PhabricatorPolicyCapability::CAN_EDIT; } } private function getLegacyRequiredEdgeCapabilities( PhabricatorApplicationTransaction $xaction) { // You don't need to have edit permission on an object to mention it or // otherwise add a relationship pointing toward it. if ($this->getIsInverseEdgeEditor()) { return null; } $edge_type = $xaction->getMetadataValue('edge:type'); switch ($edge_type) { case PhabricatorMutedByEdgeType::EDGECONST: // At time of writing, you can only write this edge for yourself, so // you don't need permissions. If you can eventually mute an object // for other users, this would need to be revisited. return null; case PhabricatorProjectSilencedEdgeType::EDGECONST: // At time of writing, you can only write this edge for yourself, so // you don't need permissions. If you can eventually silence project // for other users, this would need to be revisited. return null; case PhabricatorObjectMentionsObjectEdgeType::EDGECONST: return null; case PhabricatorProjectProjectHasMemberEdgeType::EDGECONST: $old = $xaction->getOldValue(); $new = $xaction->getNewValue(); $add = array_keys(array_diff_key($new, $old)); $rem = array_keys(array_diff_key($old, $new)); $actor_phid = $this->requireActor()->getPHID(); $is_join = (($add === array($actor_phid)) && !$rem); $is_leave = (($rem === array($actor_phid)) && !$add); if ($is_join) { // You need CAN_JOIN to join a project. return PhabricatorPolicyCapability::CAN_JOIN; } if ($is_leave) { $object = $this->object; // You usually don't need any capabilities to leave a project... if ($object->getIsMembershipLocked()) { // ...you must be able to edit to leave locked projects, though. return PhabricatorPolicyCapability::CAN_EDIT; } else { return null; } } // You need CAN_EDIT to change members other than yourself. return PhabricatorPolicyCapability::CAN_EDIT; case PhabricatorObjectHasWatcherEdgeType::EDGECONST: // See PHI1024. Watching a project does not require CAN_EDIT. return null; default: return PhabricatorPolicyCapability::CAN_EDIT; } } private function buildSubscribeTransaction( PhabricatorLiskDAO $object, array $xactions, array $changes) { if (!($object instanceof PhabricatorSubscribableInterface)) { return null; } if ($this->shouldEnableMentions($object, $xactions)) { // Identify newly mentioned users. We ignore users who were previously // mentioned so that we don't re-subscribe users after an edit of text // which mentions them. $old_texts = mpull($changes, 'getOldValue'); $new_texts = mpull($changes, 'getNewValue'); $old_phids = PhabricatorMarkupEngine::extractPHIDsFromMentions( $this->getActor(), $old_texts); $new_phids = PhabricatorMarkupEngine::extractPHIDsFromMentions( $this->getActor(), $new_texts); $phids = array_diff($new_phids, $old_phids); } else { $phids = array(); } $this->mentionedPHIDs = $phids; if ($object->getPHID()) { // Don't try to subscribe already-subscribed mentions: we want to generate // a dialog about an action having no effect if the user explicitly adds // existing CCs, but not if they merely mention existing subscribers. $phids = array_diff($phids, $this->subscribers); } if ($phids) { $users = id(new PhabricatorPeopleQuery()) ->setViewer($this->getActor()) ->withPHIDs($phids) ->execute(); $users = mpull($users, null, 'getPHID'); foreach ($phids as $key => $phid) { $user = idx($users, $phid); // Don't subscribe invalid users. if (!$user) { unset($phids[$key]); continue; } // Don't subscribe bots that get mentioned. If users truly intend // to subscribe them, they can add them explicitly, but it's generally // not useful to subscribe bots to objects. if ($user->getIsSystemAgent()) { unset($phids[$key]); continue; } // Do not subscribe mentioned users who do not have permission to see // the object. if ($object instanceof PhabricatorPolicyInterface) { $can_view = PhabricatorPolicyFilter::hasCapability( $user, $object, PhabricatorPolicyCapability::CAN_VIEW); if (!$can_view) { unset($phids[$key]); continue; } } // Don't subscribe users who are already automatically subscribed. if ($object->isAutomaticallySubscribed($phid)) { unset($phids[$key]); continue; } } $phids = array_values($phids); } if (!$phids) { return null; } $xaction = $object->getApplicationTransactionTemplate() ->setTransactionType(PhabricatorTransactions::TYPE_SUBSCRIBERS) ->setNewValue(array('+' => $phids)); return $xaction; } protected function mergeTransactions( PhabricatorApplicationTransaction $u, PhabricatorApplicationTransaction $v) { $type = $u->getTransactionType(); $xtype = $this->getModularTransactionType($type); if ($xtype) { $object = $this->object; return $xtype->mergeTransactions($object, $u, $v); } switch ($type) { case PhabricatorTransactions::TYPE_SUBSCRIBERS: return $this->mergePHIDOrEdgeTransactions($u, $v); case PhabricatorTransactions::TYPE_EDGE: $u_type = $u->getMetadataValue('edge:type'); $v_type = $v->getMetadataValue('edge:type'); if ($u_type == $v_type) { return $this->mergePHIDOrEdgeTransactions($u, $v); } return null; } // By default, do not merge the transactions. return null; } /** * Optionally expand transactions which imply other effects. For example, * resigning from a revision in Differential implies removing yourself as * a reviewer. */ protected function expandTransactions( PhabricatorLiskDAO $object, array $xactions) { $results = array(); foreach ($xactions as $xaction) { foreach ($this->expandTransaction($object, $xaction) as $expanded) { $results[] = $expanded; } } return $results; } protected function expandTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { return array($xaction); } public function getExpandedSupportTransactions( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { $xactions = array($xaction); $xactions = $this->expandSupportTransactions( $object, $xactions); if (count($xactions) == 1) { return array(); } foreach ($xactions as $index => $cxaction) { if ($cxaction === $xaction) { unset($xactions[$index]); break; } } return $xactions; } private function expandSupportTransactions( PhabricatorLiskDAO $object, array $xactions) { $this->loadSubscribers($object); $xactions = $this->applyImplicitCC($object, $xactions); $changes = $this->getRemarkupChanges($xactions); $subscribe_xaction = $this->buildSubscribeTransaction( $object, $xactions, $changes); if ($subscribe_xaction) { $xactions[] = $subscribe_xaction; } // TODO: For now, this is just a placeholder. $engine = PhabricatorMarkupEngine::getEngine('extract'); $engine->setConfig('viewer', $this->requireActor()); $block_xactions = $this->expandRemarkupBlockTransactions( $object, $xactions, $changes, $engine); foreach ($block_xactions as $xaction) { $xactions[] = $xaction; } return $xactions; } private function getRemarkupChanges(array $xactions) { $changes = array(); foreach ($xactions as $key => $xaction) { foreach ($this->getRemarkupChangesFromTransaction($xaction) as $change) { $changes[] = $change; } } return $changes; } private function getRemarkupChangesFromTransaction( PhabricatorApplicationTransaction $transaction) { return $transaction->getRemarkupChanges(); } private function expandRemarkupBlockTransactions( PhabricatorLiskDAO $object, array $xactions, array $changes, PhutilMarkupEngine $engine) { $block_xactions = $this->expandCustomRemarkupBlockTransactions( $object, $xactions, $changes, $engine); $mentioned_phids = array(); if ($this->shouldEnableMentions($object, $xactions)) { foreach ($changes as $change) { // Here, we don't care about processing only new mentions after an edit // because there is no way for an object to ever "unmention" itself on // another object, so we can ignore the old value. $engine->markupText($change->getNewValue()); $mentioned_phids += $engine->getTextMetadata( PhabricatorObjectRemarkupRule::KEY_MENTIONED_OBJECTS, array()); } } if (!$mentioned_phids) { return $block_xactions; } $mentioned_objects = id(new PhabricatorObjectQuery()) ->setViewer($this->getActor()) ->withPHIDs($mentioned_phids) ->execute(); $unmentionable_map = $this->getUnmentionablePHIDMap(); $mentionable_phids = array(); if ($this->shouldEnableMentions($object, $xactions)) { foreach ($mentioned_objects as $mentioned_object) { if ($mentioned_object instanceof PhabricatorMentionableInterface) { $mentioned_phid = $mentioned_object->getPHID(); if (isset($unmentionable_map[$mentioned_phid])) { continue; } // don't let objects mention themselves if ($object->getPHID() && $mentioned_phid == $object->getPHID()) { continue; } $mentionable_phids[$mentioned_phid] = $mentioned_phid; } } } if ($mentionable_phids) { $edge_type = PhabricatorObjectMentionsObjectEdgeType::EDGECONST; $block_xactions[] = newv(get_class(head($xactions)), array()) ->setIgnoreOnNoEffect(true) ->setTransactionType(PhabricatorTransactions::TYPE_EDGE) ->setMetadataValue('edge:type', $edge_type) ->setNewValue(array('+' => $mentionable_phids)); } return $block_xactions; } protected function expandCustomRemarkupBlockTransactions( PhabricatorLiskDAO $object, array $xactions, array $changes, PhutilMarkupEngine $engine) { return array(); } /** * Attempt to combine similar transactions into a smaller number of total * transactions. For example, two transactions which edit the title of an * object can be merged into a single edit. */ private function combineTransactions(array $xactions) { $stray_comments = array(); $result = array(); $types = array(); foreach ($xactions as $key => $xaction) { $type = $xaction->getTransactionType(); if (isset($types[$type])) { foreach ($types[$type] as $other_key) { $other_xaction = $result[$other_key]; // Don't merge transactions with different authors. For example, // don't merge Herald transactions and owners transactions. if ($other_xaction->getAuthorPHID() != $xaction->getAuthorPHID()) { continue; } $merged = $this->mergeTransactions($result[$other_key], $xaction); if ($merged) { $result[$other_key] = $merged; if ($xaction->getComment() && ($xaction->getComment() !== $merged->getComment())) { $stray_comments[] = $xaction->getComment(); } if ($result[$other_key]->getComment() && ($result[$other_key]->getComment() !== $merged->getComment())) { $stray_comments[] = $result[$other_key]->getComment(); } // Move on to the next transaction. continue 2; } } } $result[$key] = $xaction; $types[$type][] = $key; } // If we merged any comments away, restore them. foreach ($stray_comments as $comment) { $xaction = newv(get_class(head($result)), array()); $xaction->setTransactionType(PhabricatorTransactions::TYPE_COMMENT); $xaction->setComment($comment); $result[] = $xaction; } return array_values($result); } public function mergePHIDOrEdgeTransactions( PhabricatorApplicationTransaction $u, PhabricatorApplicationTransaction $v) { $result = $u->getNewValue(); foreach ($v->getNewValue() as $key => $value) { if ($u->getTransactionType() == PhabricatorTransactions::TYPE_EDGE) { if (empty($result[$key])) { $result[$key] = $value; } else { // We're merging two lists of edge adds, sets, or removes. Merge // them by merging individual PHIDs within them. $merged = $result[$key]; foreach ($value as $dst => $v_spec) { if (empty($merged[$dst])) { $merged[$dst] = $v_spec; } else { // Two transactions are trying to perform the same operation on // the same edge. Normalize the edge data and then merge it. This // allows transactions to specify how data merges execute in a // precise way. $u_spec = $merged[$dst]; if (!is_array($u_spec)) { $u_spec = array('dst' => $u_spec); } if (!is_array($v_spec)) { $v_spec = array('dst' => $v_spec); } $ux_data = idx($u_spec, 'data', array()); $vx_data = idx($v_spec, 'data', array()); $merged_data = $this->mergeEdgeData( $u->getMetadataValue('edge:type'), $ux_data, $vx_data); $u_spec['data'] = $merged_data; $merged[$dst] = $u_spec; } } $result[$key] = $merged; } } else { $result[$key] = array_merge($value, idx($result, $key, array())); } } $u->setNewValue($result); // When combining an "ignore" transaction with a normal transaction, make // sure we don't propagate the "ignore" flag. if (!$v->getIgnoreOnNoEffect()) { $u->setIgnoreOnNoEffect(false); } return $u; } protected function mergeEdgeData($type, array $u, array $v) { return $v + $u; } protected function getPHIDTransactionNewValue( PhabricatorApplicationTransaction $xaction, $old = null) { if ($old !== null) { $old = array_fuse($old); } else { $old = array_fuse($xaction->getOldValue()); } return $this->getPHIDList($old, $xaction->getNewValue()); } public function getPHIDList(array $old, array $new) { $new_add = idx($new, '+', array()); unset($new['+']); $new_rem = idx($new, '-', array()); unset($new['-']); $new_set = idx($new, '=', null); if ($new_set !== null) { $new_set = array_fuse($new_set); } unset($new['=']); if ($new) { throw new Exception( pht( "Invalid '%s' value for PHID transaction. Value should contain only ". "keys '%s' (add PHIDs), '%s' (remove PHIDs) and '%s' (set PHIDS).", 'new', '+', '-', '=')); } $result = array(); foreach ($old as $phid) { if ($new_set !== null && empty($new_set[$phid])) { continue; } $result[$phid] = $phid; } if ($new_set !== null) { foreach ($new_set as $phid) { $result[$phid] = $phid; } } foreach ($new_add as $phid) { $result[$phid] = $phid; } foreach ($new_rem as $phid) { unset($result[$phid]); } return array_values($result); } protected function getEdgeTransactionNewValue( PhabricatorApplicationTransaction $xaction) { $new = $xaction->getNewValue(); $new_add = idx($new, '+', array()); unset($new['+']); $new_rem = idx($new, '-', array()); unset($new['-']); $new_set = idx($new, '=', null); unset($new['=']); if ($new) { throw new Exception( pht( "Invalid '%s' value for Edge transaction. Value should contain only ". "keys '%s' (add edges), '%s' (remove edges) and '%s' (set edges).", 'new', '+', '-', '=')); } $old = $xaction->getOldValue(); $lists = array($new_set, $new_add, $new_rem); foreach ($lists as $list) { $this->checkEdgeList($list, $xaction->getMetadataValue('edge:type')); } $result = array(); foreach ($old as $dst_phid => $edge) { if ($new_set !== null && empty($new_set[$dst_phid])) { continue; } $result[$dst_phid] = $this->normalizeEdgeTransactionValue( $xaction, $edge, $dst_phid); } if ($new_set !== null) { foreach ($new_set as $dst_phid => $edge) { $result[$dst_phid] = $this->normalizeEdgeTransactionValue( $xaction, $edge, $dst_phid); } } foreach ($new_add as $dst_phid => $edge) { $result[$dst_phid] = $this->normalizeEdgeTransactionValue( $xaction, $edge, $dst_phid); } foreach ($new_rem as $dst_phid => $edge) { unset($result[$dst_phid]); } return $result; } private function checkEdgeList($list, $edge_type) { if (!$list) { return; } foreach ($list as $key => $item) { if (phid_get_type($key) === PhabricatorPHIDConstants::PHID_TYPE_UNKNOWN) { throw new Exception( pht( 'Edge transactions must have destination PHIDs as in edge '. 'lists (found key "%s" on transaction of type "%s").', $key, $edge_type)); } if (!is_array($item) && $item !== $key) { throw new Exception( pht( 'Edge transactions must have PHIDs or edge specs as values '. '(found value "%s" on transaction of type "%s").', $item, $edge_type)); } } } private function normalizeEdgeTransactionValue( PhabricatorApplicationTransaction $xaction, $edge, $dst_phid) { if (!is_array($edge)) { if ($edge != $dst_phid) { throw new Exception( pht( 'Transaction edge data must either be the edge PHID or an edge '. 'specification dictionary.')); } $edge = array(); } else { foreach ($edge as $key => $value) { switch ($key) { case 'src': case 'dst': case 'type': case 'data': case 'dateCreated': case 'dateModified': case 'seq': case 'dataID': break; default: throw new Exception( pht( 'Transaction edge specification contains unexpected key "%s".', $key)); } } } $edge['dst'] = $dst_phid; $edge_type = $xaction->getMetadataValue('edge:type'); if (empty($edge['type'])) { $edge['type'] = $edge_type; } else { if ($edge['type'] != $edge_type) { $this_type = $edge['type']; throw new Exception( pht( "Edge transaction includes edge of type '%s', but ". "transaction is of type '%s'. Each edge transaction ". "must alter edges of only one type.", $this_type, $edge_type)); } } if (!isset($edge['data'])) { $edge['data'] = array(); } return $edge; } protected function sortTransactions(array $xactions) { $head = array(); $tail = array(); // Move bare comments to the end, so the actions precede them. foreach ($xactions as $xaction) { $type = $xaction->getTransactionType(); if ($type == PhabricatorTransactions::TYPE_COMMENT) { $tail[] = $xaction; } else { $head[] = $xaction; } } return array_values(array_merge($head, $tail)); } protected function filterTransactions( PhabricatorLiskDAO $object, array $xactions) { $type_comment = PhabricatorTransactions::TYPE_COMMENT; $type_mfa = PhabricatorTransactions::TYPE_MFA; $no_effect = array(); $has_comment = false; $any_effect = false; $meta_xactions = array(); foreach ($xactions as $key => $xaction) { if ($xaction->getTransactionType() === $type_mfa) { $meta_xactions[$key] = $xaction; continue; } if ($this->transactionHasEffect($object, $xaction)) { if ($xaction->getTransactionType() != $type_comment) { $any_effect = true; } } else if ($xaction->getIgnoreOnNoEffect()) { unset($xactions[$key]); } else { $no_effect[$key] = $xaction; } if ($xaction->hasComment()) { $has_comment = true; } } // If every transaction is a meta-transaction applying to the transaction // group, these transactions are junk. if (count($meta_xactions) == count($xactions)) { $no_effect = $xactions; $any_effect = false; } if (!$no_effect) { return $xactions; } // If none of the transactions have an effect, the meta-transactions also // have no effect. Add them to the "no effect" list so we get a full set // of errors for everything. if (!$any_effect && !$has_comment) { $no_effect += $meta_xactions; } if (!$this->getContinueOnNoEffect() && !$this->getIsPreview()) { throw new PhabricatorApplicationTransactionNoEffectException( $no_effect, $any_effect, $has_comment); } if (!$any_effect && !$has_comment) { // If we only have empty comment transactions, just drop them all. return array(); } foreach ($no_effect as $key => $xaction) { if ($xaction->hasComment()) { $xaction->setTransactionType($type_comment); $xaction->setOldValue(null); $xaction->setNewValue(null); } else { unset($xactions[$key]); } } return $xactions; } /** * Hook for validating transactions. This callback will be invoked for each * available transaction type, even if an edit does not apply any transactions * of that type. This allows you to raise exceptions when required fields are * missing, by detecting that the object has no field value and there is no * transaction which sets one. * * @param PhabricatorLiskDAO Object being edited. * @param string Transaction type to validate. * @param list Transactions of given type, * which may be empty if the edit does not apply any transactions of the * given type. * @return list List of * validation errors. */ protected function validateTransaction( PhabricatorLiskDAO $object, $type, array $xactions) { $errors = array(); $xtype = $this->getModularTransactionType($type); if ($xtype) { $errors[] = $xtype->validateTransactions($object, $xactions); } switch ($type) { case PhabricatorTransactions::TYPE_VIEW_POLICY: $errors[] = $this->validatePolicyTransaction( $object, $xactions, $type, PhabricatorPolicyCapability::CAN_VIEW); break; case PhabricatorTransactions::TYPE_EDIT_POLICY: $errors[] = $this->validatePolicyTransaction( $object, $xactions, $type, PhabricatorPolicyCapability::CAN_EDIT); break; case PhabricatorTransactions::TYPE_SPACE: $errors[] = $this->validateSpaceTransactions( $object, $xactions, $type); break; case PhabricatorTransactions::TYPE_SUBTYPE: $errors[] = $this->validateSubtypeTransactions( $object, $xactions, $type); break; case PhabricatorTransactions::TYPE_MFA: $errors[] = $this->validateMFATransactions( $object, $xactions, $type); break; case PhabricatorTransactions::TYPE_CUSTOMFIELD: $groups = array(); foreach ($xactions as $xaction) { $groups[$xaction->getMetadataValue('customfield:key')][] = $xaction; } $field_list = PhabricatorCustomField::getObjectFields( $object, PhabricatorCustomField::ROLE_EDIT); $field_list->setViewer($this->getActor()); $role_xactions = PhabricatorCustomField::ROLE_APPLICATIONTRANSACTIONS; foreach ($field_list->getFields() as $field) { if (!$field->shouldEnableForRole($role_xactions)) { continue; } $errors[] = $field->validateApplicationTransactions( $this, $type, idx($groups, $field->getFieldKey(), array())); } break; } return array_mergev($errors); } public function validatePolicyTransaction( PhabricatorLiskDAO $object, array $xactions, $transaction_type, $capability) { $actor = $this->requireActor(); $errors = array(); // Note $this->xactions is necessary; $xactions is $this->xactions of // $transaction_type $policy_object = $this->adjustObjectForPolicyChecks( $object, $this->xactions); // Make sure the user isn't editing away their ability to $capability this // object. foreach ($xactions as $xaction) { try { PhabricatorPolicyFilter::requireCapabilityWithForcedPolicy( $actor, $policy_object, $capability, $xaction->getNewValue()); } catch (PhabricatorPolicyException $ex) { $errors[] = new PhabricatorApplicationTransactionValidationError( $transaction_type, pht('Invalid'), pht( 'You can not select this %s policy, because you would no longer '. 'be able to %s the object.', $capability, $capability), $xaction); } } if ($this->getIsNewObject()) { if (!$xactions) { $has_capability = PhabricatorPolicyFilter::hasCapability( $actor, $policy_object, $capability); if (!$has_capability) { $errors[] = new PhabricatorApplicationTransactionValidationError( $transaction_type, pht('Invalid'), pht( 'The selected %s policy excludes you. Choose a %s policy '. 'which allows you to %s the object.', $capability, $capability, $capability)); } } } return $errors; } private function validateSpaceTransactions( PhabricatorLiskDAO $object, array $xactions, $transaction_type) { $errors = array(); $actor = $this->getActor(); $has_spaces = PhabricatorSpacesNamespaceQuery::getViewerSpacesExist($actor); $actor_spaces = PhabricatorSpacesNamespaceQuery::getViewerSpaces($actor); $active_spaces = PhabricatorSpacesNamespaceQuery::getViewerActiveSpaces( $actor); foreach ($xactions as $xaction) { $space_phid = $xaction->getNewValue(); if ($space_phid === null) { if (!$has_spaces) { // The install doesn't have any spaces, so this is fine. continue; } // The install has some spaces, so every object needs to be put // in a valid space. $errors[] = new PhabricatorApplicationTransactionValidationError( $transaction_type, pht('Invalid'), pht('You must choose a space for this object.'), $xaction); continue; } // If the PHID isn't `null`, it needs to be a valid space that the // viewer can see. if (empty($actor_spaces[$space_phid])) { $errors[] = new PhabricatorApplicationTransactionValidationError( $transaction_type, pht('Invalid'), pht( 'You can not shift this object in the selected space, because '. 'the space does not exist or you do not have access to it.'), $xaction); } else if (empty($active_spaces[$space_phid])) { // It's OK to edit objects in an archived space, so just move on if // we aren't adjusting the value. $old_space_phid = $this->getTransactionOldValue($object, $xaction); if ($space_phid == $old_space_phid) { continue; } $errors[] = new PhabricatorApplicationTransactionValidationError( $transaction_type, pht('Archived'), pht( 'You can not shift this object into the selected space, because '. 'the space is archived. Objects can not be created inside (or '. 'moved into) archived spaces.'), $xaction); } } return $errors; } private function validateSubtypeTransactions( PhabricatorLiskDAO $object, array $xactions, $transaction_type) { $errors = array(); $map = $object->newEditEngineSubtypeMap(); $old = $object->getEditEngineSubtype(); foreach ($xactions as $xaction) { $new = $xaction->getNewValue(); if ($old == $new) { continue; } if (!$map->isValidSubtype($new)) { $errors[] = new PhabricatorApplicationTransactionValidationError( $transaction_type, pht('Invalid'), pht( 'The subtype "%s" is not a valid subtype.', $new), $xaction); continue; } } return $errors; } private function validateMFATransactions( PhabricatorLiskDAO $object, array $xactions, $transaction_type) { $errors = array(); $factors = id(new PhabricatorAuthFactorConfigQuery()) ->setViewer($this->getActor()) ->withUserPHIDs(array($this->getActingAsPHID())) ->withFactorProviderStatuses( array( PhabricatorAuthFactorProviderStatus::STATUS_ACTIVE, PhabricatorAuthFactorProviderStatus::STATUS_DEPRECATED, )) ->execute(); foreach ($xactions as $xaction) { if (!$factors) { $errors[] = new PhabricatorApplicationTransactionValidationError( $transaction_type, pht('No MFA'), pht( 'You do not have any MFA factors attached to your account, so '. 'you can not sign this transaction group with MFA. Add MFA to '. 'your account in Settings.'), $xaction); } } if ($xactions) { $this->setShouldRequireMFA(true); } return $errors; } protected function adjustObjectForPolicyChecks( PhabricatorLiskDAO $object, array $xactions) { $copy = clone $object; foreach ($xactions as $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorTransactions::TYPE_SUBSCRIBERS: $clone_xaction = clone $xaction; $clone_xaction->setOldValue(array_values($this->subscribers)); $clone_xaction->setNewValue( $this->getPHIDTransactionNewValue( $clone_xaction)); PhabricatorPolicyRule::passTransactionHintToRule( $copy, new PhabricatorSubscriptionsSubscribersPolicyRule(), array_fuse($clone_xaction->getNewValue())); break; case PhabricatorTransactions::TYPE_SPACE: $space_phid = $this->getTransactionNewValue($object, $xaction); $copy->setSpacePHID($space_phid); break; } } return $copy; } protected function validateAllTransactions( PhabricatorLiskDAO $object, array $xactions) { return array(); } /** * Check for a missing text field. * * A text field is missing if the object has no value and there are no * transactions which set a value, or if the transactions remove the value. * This method is intended to make implementing @{method:validateTransaction} * more convenient: * * $missing = $this->validateIsEmptyTextField( * $object->getName(), * $xactions); * * This will return `true` if the net effect of the object and transactions * is an empty field. * * @param wild Current field value. * @param list Transactions editing the * field. * @return bool True if the field will be an empty text field after edits. */ protected function validateIsEmptyTextField($field_value, array $xactions) { if (strlen($field_value) && empty($xactions)) { return false; } if ($xactions && strlen(last($xactions)->getNewValue())) { return false; } return true; } /* -( Implicit CCs )------------------------------------------------------- */ /** * When a user interacts with an object, we might want to add them to CC. */ final public function applyImplicitCC( PhabricatorLiskDAO $object, array $xactions) { if (!($object instanceof PhabricatorSubscribableInterface)) { // If the object isn't subscribable, we can't CC them. return $xactions; } $actor_phid = $this->getActingAsPHID(); $type_user = PhabricatorPeopleUserPHIDType::TYPECONST; if (phid_get_type($actor_phid) != $type_user) { // Transactions by application actors like Herald, Harbormaster and // Diffusion should not CC the applications. return $xactions; } if ($object->isAutomaticallySubscribed($actor_phid)) { // If they're auto-subscribed, don't CC them. return $xactions; } $should_cc = false; foreach ($xactions as $xaction) { if ($this->shouldImplyCC($object, $xaction)) { $should_cc = true; break; } } if (!$should_cc) { // Only some types of actions imply a CC (like adding a comment). return $xactions; } if ($object->getPHID()) { if (isset($this->subscribers[$actor_phid])) { // If the user is already subscribed, don't implicitly CC them. return $xactions; } $unsub = PhabricatorEdgeQuery::loadDestinationPHIDs( $object->getPHID(), PhabricatorObjectHasUnsubscriberEdgeType::EDGECONST); $unsub = array_fuse($unsub); if (isset($unsub[$actor_phid])) { // If the user has previously unsubscribed from this object explicitly, // don't implicitly CC them. return $xactions; } } $actor = $this->getActor(); $user = id(new PhabricatorPeopleQuery()) ->setViewer($actor) ->withPHIDs(array($actor_phid)) ->executeOne(); if (!$user) { return $xactions; } // When a bot acts (usually via the API), don't automatically subscribe // them as a side effect. They can always subscribe explicitly if they // want, and bot subscriptions normally just clutter things up since bots // usually do not read email. if ($user->getIsSystemAgent()) { return $xactions; } $xaction = newv(get_class(head($xactions)), array()); $xaction->setTransactionType(PhabricatorTransactions::TYPE_SUBSCRIBERS); $xaction->setNewValue(array('+' => array($actor_phid))); array_unshift($xactions, $xaction); return $xactions; } protected function shouldImplyCC( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { return $xaction->isCommentTransaction(); } /* -( Sending Mail )------------------------------------------------------- */ /** * @task mail */ protected function shouldSendMail( PhabricatorLiskDAO $object, array $xactions) { return false; } /** * @task mail */ private function buildMail( PhabricatorLiskDAO $object, array $xactions) { $email_to = $this->mailToPHIDs; $email_cc = $this->mailCCPHIDs; $email_cc = array_merge($email_cc, $this->heraldEmailPHIDs); $unexpandable = $this->mailUnexpandablePHIDs; if (!is_array($unexpandable)) { $unexpandable = array(); } $messages = $this->buildMailWithRecipients( $object, $xactions, $email_to, $email_cc, $unexpandable); $this->runHeraldMailRules($messages); return $messages; } private function buildMailWithRecipients( PhabricatorLiskDAO $object, array $xactions, array $email_to, array $email_cc, array $unexpandable) { $targets = $this->buildReplyHandler($object) ->setUnexpandablePHIDs($unexpandable) ->getMailTargets($email_to, $email_cc); // Set this explicitly before we start swapping out the effective actor. $this->setActingAsPHID($this->getActingAsPHID()); $xaction_phids = mpull($xactions, 'getPHID'); $messages = array(); foreach ($targets as $target) { $original_actor = $this->getActor(); $viewer = $target->getViewer(); $this->setActor($viewer); $locale = PhabricatorEnv::beginScopedLocale($viewer->getTranslation()); $caught = null; $mail = null; try { // Reload the transactions for the current viewer. if ($xaction_phids) { $query = PhabricatorApplicationTransactionQuery::newQueryForObject( $object); $mail_xactions = $query ->setViewer($viewer) ->withObjectPHIDs(array($object->getPHID())) ->withPHIDs($xaction_phids) ->execute(); // Sort the mail transactions in the input order. $mail_xactions = mpull($mail_xactions, null, 'getPHID'); $mail_xactions = array_select_keys($mail_xactions, $xaction_phids); $mail_xactions = array_values($mail_xactions); } else { $mail_xactions = array(); } // Reload handles for the current viewer. This covers older code which // emits a list of handle PHIDs upfront. $this->loadHandles($mail_xactions); $mail = $this->buildMailForTarget($object, $mail_xactions, $target); if ($mail) { if ($this->mustEncrypt) { $mail ->setMustEncrypt(true) ->setMustEncryptReasons($this->mustEncrypt); } } } catch (Exception $ex) { $caught = $ex; } $this->setActor($original_actor); unset($locale); if ($caught) { throw $ex; } if ($mail) { $messages[] = $mail; } } return $messages; } protected function getTransactionsForMail( PhabricatorLiskDAO $object, array $xactions) { return $xactions; } private function buildMailForTarget( PhabricatorLiskDAO $object, array $xactions, PhabricatorMailTarget $target) { // Check if any of the transactions are visible for this viewer. If we // don't have any visible transactions, don't send the mail. $any_visible = false; foreach ($xactions as $xaction) { if (!$xaction->shouldHideForMail($xactions)) { $any_visible = true; break; } } if (!$any_visible) { return null; } $mail_xactions = $this->getTransactionsForMail($object, $xactions); $mail = $this->buildMailTemplate($object); $body = $this->buildMailBody($object, $mail_xactions); $mail_tags = $this->getMailTags($object, $mail_xactions); $action = $this->getMailAction($object, $mail_xactions); $stamps = $this->generateMailStamps($object, $this->mailStamps); if (PhabricatorEnv::getEnvConfig('metamta.email-preferences')) { $this->addEmailPreferenceSectionToMailBody( $body, $object, $mail_xactions); } $muted_phids = $this->mailMutedPHIDs; if (!is_array($muted_phids)) { $muted_phids = array(); } $mail ->setSensitiveContent(false) ->setFrom($this->getActingAsPHID()) ->setSubjectPrefix($this->getMailSubjectPrefix()) ->setVarySubjectPrefix('['.$action.']') ->setThreadID($this->getMailThreadID($object), $this->getIsNewObject()) ->setRelatedPHID($object->getPHID()) ->setExcludeMailRecipientPHIDs($this->getExcludeMailRecipientPHIDs()) ->setMutedPHIDs($muted_phids) ->setForceHeraldMailRecipientPHIDs($this->heraldForcedEmailPHIDs) ->setMailTags($mail_tags) ->setIsBulk(true) ->setBody($body->render()) ->setHTMLBody($body->renderHTML()); foreach ($body->getAttachments() as $attachment) { $mail->addAttachment($attachment); } if ($this->heraldHeader) { $mail->addHeader('X-Herald-Rules', $this->heraldHeader); } if ($object instanceof PhabricatorProjectInterface) { $this->addMailProjectMetadata($object, $mail); } if ($this->getParentMessageID()) { $mail->setParentMessageID($this->getParentMessageID()); } // If we have stamps, attach the raw dictionary version (not the actual // objects) to the mail so that debugging tools can see what we used to // render the final list. if ($this->mailStamps) { $mail->setMailStampMetadata($this->mailStamps); } // If we have rendered stamps, attach them to the mail. if ($stamps) { $mail->setMailStamps($stamps); } return $target->willSendMail($mail); } private function addMailProjectMetadata( PhabricatorLiskDAO $object, PhabricatorMetaMTAMail $template) { $project_phids = PhabricatorEdgeQuery::loadDestinationPHIDs( $object->getPHID(), PhabricatorProjectObjectHasProjectEdgeType::EDGECONST); if (!$project_phids) { return; } // TODO: This viewer isn't quite right. It would be slightly better to use // the mail recipient, but that's not very easy given the way rendering // works today. $handles = id(new PhabricatorHandleQuery()) ->setViewer($this->requireActor()) ->withPHIDs($project_phids) ->execute(); $project_tags = array(); foreach ($handles as $handle) { if (!$handle->isComplete()) { continue; } $project_tags[] = '<'.$handle->getObjectName().'>'; } if (!$project_tags) { return; } $project_tags = implode(', ', $project_tags); $template->addHeader('X-Phabricator-Projects', $project_tags); } protected function getMailThreadID(PhabricatorLiskDAO $object) { return $object->getPHID(); } /** * @task mail */ protected function getStrongestAction( PhabricatorLiskDAO $object, array $xactions) { return head(msortv($xactions, 'newActionStrengthSortVector')); } /** * @task mail */ protected function buildReplyHandler(PhabricatorLiskDAO $object) { throw new Exception(pht('Capability not supported.')); } /** * @task mail */ protected function getMailSubjectPrefix() { throw new Exception(pht('Capability not supported.')); } /** * @task mail */ protected function getMailTags( PhabricatorLiskDAO $object, array $xactions) { $tags = array(); foreach ($xactions as $xaction) { $tags[] = $xaction->getMailTags(); } return array_mergev($tags); } /** * @task mail */ public function getMailTagsMap() { // TODO: We should move shared mail tags, like "comment", here. return array(); } /** * @task mail */ protected function getMailAction( PhabricatorLiskDAO $object, array $xactions) { return $this->getStrongestAction($object, $xactions)->getActionName(); } /** * @task mail */ protected function buildMailTemplate(PhabricatorLiskDAO $object) { throw new Exception(pht('Capability not supported.')); } /** * @task mail */ protected function getMailTo(PhabricatorLiskDAO $object) { throw new Exception(pht('Capability not supported.')); } protected function newMailUnexpandablePHIDs(PhabricatorLiskDAO $object) { return array(); } /** * @task mail */ protected function getMailCC(PhabricatorLiskDAO $object) { $phids = array(); $has_support = false; if ($object instanceof PhabricatorSubscribableInterface) { $phid = $object->getPHID(); $phids[] = PhabricatorSubscribersQuery::loadSubscribersForPHID($phid); $has_support = true; } if ($object instanceof PhabricatorProjectInterface) { $project_phids = PhabricatorEdgeQuery::loadDestinationPHIDs( $object->getPHID(), PhabricatorProjectObjectHasProjectEdgeType::EDGECONST); if ($project_phids) { $projects = id(new PhabricatorProjectQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withPHIDs($project_phids) ->needWatchers(true) ->execute(); $watcher_phids = array(); foreach ($projects as $project) { foreach ($project->getAllAncestorWatcherPHIDs() as $phid) { $watcher_phids[$phid] = $phid; } } if ($watcher_phids) { // We need to do a visibility check for all the watchers, as // watching a project is not a guarantee that you can see objects // associated with it. $users = id(new PhabricatorPeopleQuery()) ->setViewer($this->requireActor()) ->withPHIDs($watcher_phids) ->execute(); $watchers = array(); foreach ($users as $user) { $can_see = PhabricatorPolicyFilter::hasCapability( $user, $object, PhabricatorPolicyCapability::CAN_VIEW); if ($can_see) { $watchers[] = $user->getPHID(); } } $phids[] = $watchers; } } $has_support = true; } if (!$has_support) { throw new Exception( pht('The object being edited does not implement any standard '. 'interfaces (like PhabricatorSubscribableInterface) which allow '. 'CCs to be generated automatically. Override the "getMailCC()" '. 'method and generate CCs explicitly.')); } return array_mergev($phids); } /** * @task mail */ protected function buildMailBody( PhabricatorLiskDAO $object, array $xactions) { $body = id(new PhabricatorMetaMTAMailBody()) ->setViewer($this->requireActor()) ->setContextObject($object); $button_label = $this->getObjectLinkButtonLabelForMail($object); $button_uri = $this->getObjectLinkButtonURIForMail($object); $this->addHeadersAndCommentsToMailBody( $body, $xactions, $button_label, $button_uri); $this->addCustomFieldsToMailBody($body, $object, $xactions); return $body; } protected function getObjectLinkButtonLabelForMail( PhabricatorLiskDAO $object) { return null; } protected function getObjectLinkButtonURIForMail( PhabricatorLiskDAO $object) { // Most objects define a "getURI()" method which does what we want, but // this isn't formally part of an interface at time of writing. Try to // call the method, expecting an exception if it does not exist. try { $uri = $object->getURI(); return PhabricatorEnv::getProductionURI($uri); } catch (Exception $ex) { return null; } } /** * @task mail */ protected function addEmailPreferenceSectionToMailBody( PhabricatorMetaMTAMailBody $body, PhabricatorLiskDAO $object, array $xactions) { $href = PhabricatorEnv::getProductionURI( '/settings/panel/emailpreferences/'); $body->addLinkSection(pht('EMAIL PREFERENCES'), $href); } /** * @task mail */ protected function addHeadersAndCommentsToMailBody( PhabricatorMetaMTAMailBody $body, array $xactions, $object_label = null, $object_uri = null) { // First, remove transactions which shouldn't be rendered in mail. foreach ($xactions as $key => $xaction) { if ($xaction->shouldHideForMail($xactions)) { unset($xactions[$key]); } } $headers = array(); $headers_html = array(); $comments = array(); $details = array(); $seen_comment = false; foreach ($xactions as $xaction) { // Most mail has zero or one comments. In these cases, we render the // "alice added a comment." transaction in the header, like a normal // transaction. // Some mail, like Differential undraft mail or "!history" mail, may // have two or more comments. In these cases, we'll put the first // "alice added a comment." transaction in the header normally, but // move the other transactions down so they provide context above the // actual comment. $comment = $this->getBodyForTextMail($xaction); if ($comment !== null) { $is_comment = true; $comments[] = array( 'xaction' => $xaction, 'comment' => $comment, 'initial' => !$seen_comment, ); } else { $is_comment = false; } if (!$is_comment || !$seen_comment) { $header = $this->getTitleForTextMail($xaction); if ($header !== null) { $headers[] = $header; } $header_html = $this->getTitleForHTMLMail($xaction); if ($header_html !== null) { $headers_html[] = $header_html; } } if ($xaction->hasChangeDetailsForMail()) { $details[] = $xaction; } if ($is_comment) { $seen_comment = true; } } $headers_text = implode("\n", $headers); $body->addRawPlaintextSection($headers_text); $headers_html = phutil_implode_html(phutil_tag('br'), $headers_html); $header_button = null; if ($object_label !== null && $object_uri !== null) { $button_style = array( 'text-decoration: none;', 'padding: 4px 8px;', 'margin: 0 8px 8px;', 'float: right;', 'color: #464C5C;', 'font-weight: bold;', 'border-radius: 3px;', 'background-color: #F7F7F9;', 'background-image: linear-gradient(to bottom,#fff,#f1f0f1);', 'display: inline-block;', 'border: 1px solid rgba(71,87,120,.2);', ); $header_button = phutil_tag( 'a', array( 'style' => implode(' ', $button_style), 'href' => $object_uri, ), $object_label); } $xactions_style = array(); $header_action = phutil_tag( 'td', array(), $header_button); $header_action = phutil_tag( 'td', array( 'style' => implode(' ', $xactions_style), ), array( $headers_html, // Add an extra newline to prevent the "View Object" button from // running into the transaction text in Mail.app text snippet // previews. "\n", )); $headers_html = phutil_tag( 'table', array(), phutil_tag('tr', array(), array($header_action, $header_button))); $body->addRawHTMLSection($headers_html); foreach ($comments as $spec) { $xaction = $spec['xaction']; $comment = $spec['comment']; $is_initial = $spec['initial']; // If this is not the first comment in the mail, add the header showing // who wrote the comment immediately above the comment. if (!$is_initial) { $header = $this->getTitleForTextMail($xaction); if ($header !== null) { $body->addRawPlaintextSection($header); } $header_html = $this->getTitleForHTMLMail($xaction); if ($header_html !== null) { $body->addRawHTMLSection($header_html); } } $body->addRemarkupSection(null, $comment); } foreach ($details as $xaction) { $details = $xaction->renderChangeDetailsForMail($body->getViewer()); if ($details !== null) { $label = $this->getMailDiffSectionHeader($xaction); $body->addHTMLSection($label, $details); } } } private function getMailDiffSectionHeader($xaction) { $type = $xaction->getTransactionType(); $xtype = $this->getModularTransactionType($type); if ($xtype) { return $xtype->getMailDiffSectionHeader(); } return pht('EDIT DETAILS'); } /** * @task mail */ protected function addCustomFieldsToMailBody( PhabricatorMetaMTAMailBody $body, PhabricatorLiskDAO $object, array $xactions) { if ($object instanceof PhabricatorCustomFieldInterface) { $field_list = PhabricatorCustomField::getObjectFields( $object, PhabricatorCustomField::ROLE_TRANSACTIONMAIL); $field_list->setViewer($this->getActor()); $field_list->readFieldsFromStorage($object); foreach ($field_list->getFields() as $field) { $field->updateTransactionMailBody( $body, $this, $xactions); } } } /** * @task mail */ private function runHeraldMailRules(array $messages) { foreach ($messages as $message) { $engine = new HeraldEngine(); $adapter = id(new PhabricatorMailOutboundMailHeraldAdapter()) ->setObject($message); $rules = $engine->loadRulesForAdapter($adapter); $effects = $engine->applyRules($rules, $adapter); $engine->applyEffects($effects, $adapter, $rules); } } /* -( Publishing Feed Stories )-------------------------------------------- */ /** * @task feed */ protected function shouldPublishFeedStory( PhabricatorLiskDAO $object, array $xactions) { return false; } /** * @task feed */ protected function getFeedStoryType() { return 'PhabricatorApplicationTransactionFeedStory'; } /** * @task feed */ protected function getFeedRelatedPHIDs( PhabricatorLiskDAO $object, array $xactions) { $phids = array( $object->getPHID(), $this->getActingAsPHID(), ); if ($object instanceof PhabricatorProjectInterface) { $project_phids = PhabricatorEdgeQuery::loadDestinationPHIDs( $object->getPHID(), PhabricatorProjectObjectHasProjectEdgeType::EDGECONST); foreach ($project_phids as $project_phid) { $phids[] = $project_phid; } } return $phids; } /** * @task feed */ protected function getFeedNotifyPHIDs( PhabricatorLiskDAO $object, array $xactions) { // If some transactions are forcing notification delivery, add the forced // recipients to the notify list. $force_list = array(); foreach ($xactions as $xaction) { $force_phids = $xaction->getForceNotifyPHIDs(); if (!$force_phids) { continue; } foreach ($force_phids as $force_phid) { $force_list[] = $force_phid; } } $to_list = $this->getMailTo($object); $cc_list = $this->getMailCC($object); $full_list = array_merge($force_list, $to_list, $cc_list); $full_list = array_fuse($full_list); return array_keys($full_list); } /** * @task feed */ protected function getFeedStoryData( PhabricatorLiskDAO $object, array $xactions) { $xactions = msortv($xactions, 'newActionStrengthSortVector'); return array( 'objectPHID' => $object->getPHID(), 'transactionPHIDs' => mpull($xactions, 'getPHID'), ); } /** * @task feed */ protected function publishFeedStory( PhabricatorLiskDAO $object, array $xactions, array $mailed_phids) { // Remove transactions which don't publish feed stories or notifications. // These never show up anywhere, so we don't need to do anything with them. foreach ($xactions as $key => $xaction) { if (!$xaction->shouldHideForFeed()) { continue; } if (!$xaction->shouldHideForNotifications()) { continue; } unset($xactions[$key]); } if (!$xactions) { return; } $related_phids = $this->feedRelatedPHIDs; $subscribed_phids = $this->feedNotifyPHIDs; // Remove muted users from the subscription list so they don't get // notifications, either. $muted_phids = $this->mailMutedPHIDs; if (!is_array($muted_phids)) { $muted_phids = array(); } $subscribed_phids = array_fuse($subscribed_phids); foreach ($muted_phids as $muted_phid) { unset($subscribed_phids[$muted_phid]); } $subscribed_phids = array_values($subscribed_phids); $story_type = $this->getFeedStoryType(); $story_data = $this->getFeedStoryData($object, $xactions); $unexpandable_phids = $this->mailUnexpandablePHIDs; if (!is_array($unexpandable_phids)) { $unexpandable_phids = array(); } id(new PhabricatorFeedStoryPublisher()) ->setStoryType($story_type) ->setStoryData($story_data) ->setStoryTime(time()) ->setStoryAuthorPHID($this->getActingAsPHID()) ->setRelatedPHIDs($related_phids) ->setPrimaryObjectPHID($object->getPHID()) ->setSubscribedPHIDs($subscribed_phids) ->setUnexpandablePHIDs($unexpandable_phids) ->setMailRecipientPHIDs($mailed_phids) ->setMailTags($this->getMailTags($object, $xactions)) ->publish(); } /* -( Search Index )------------------------------------------------------- */ /** * @task search */ protected function supportsSearch() { return false; } /* -( Herald Integration )-------------------------------------------------- */ protected function shouldApplyHeraldRules( PhabricatorLiskDAO $object, array $xactions) { return false; } protected function buildHeraldAdapter( PhabricatorLiskDAO $object, array $xactions) { throw new Exception(pht('No herald adapter specified.')); } private function setHeraldAdapter(HeraldAdapter $adapter) { $this->heraldAdapter = $adapter; return $this; } protected function getHeraldAdapter() { return $this->heraldAdapter; } private function setHeraldTranscript(HeraldTranscript $transcript) { $this->heraldTranscript = $transcript; return $this; } protected function getHeraldTranscript() { return $this->heraldTranscript; } private function applyHeraldRules( PhabricatorLiskDAO $object, array $xactions) { $adapter = $this->buildHeraldAdapter($object, $xactions) ->setContentSource($this->getContentSource()) ->setIsNewObject($this->getIsNewObject()) ->setActingAsPHID($this->getActingAsPHID()) ->setAppliedTransactions($xactions); if ($this->getApplicationEmail()) { $adapter->setApplicationEmail($this->getApplicationEmail()); } // If this editor is operating in silent mode, tell Herald that we aren't // going to send any mail. This allows it to skip "the first time this // rule matches, send me an email" rules which would otherwise match even // though we aren't going to send any mail. if ($this->getIsSilent()) { $adapter->setForbiddenAction( HeraldMailableState::STATECONST, HeraldCoreStateReasons::REASON_SILENT); } $xscript = HeraldEngine::loadAndApplyRules($adapter); $this->setHeraldAdapter($adapter); $this->setHeraldTranscript($xscript); if ($adapter instanceof HarbormasterBuildableAdapterInterface) { $buildable_phid = $adapter->getHarbormasterBuildablePHID(); HarbormasterBuildable::applyBuildPlans( $buildable_phid, $adapter->getHarbormasterContainerPHID(), $adapter->getQueuedHarbormasterBuildRequests()); // Whether we queued any builds or not, any automatic buildable for this // object is now done preparing builds and can transition into a // completed status. $buildables = id(new HarbormasterBuildableQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withManualBuildables(false) ->withBuildablePHIDs(array($buildable_phid)) ->execute(); foreach ($buildables as $buildable) { // If this buildable has already moved beyond preparation, we don't // need to nudge it again. if (!$buildable->isPreparing()) { continue; } $buildable->sendMessage( $this->getActor(), HarbormasterMessageType::BUILDABLE_BUILD, true); } } $this->mustEncrypt = $adapter->getMustEncryptReasons(); // See PHI1134. Propagate "Must Encrypt" state to sub-editors. foreach ($this->subEditors as $sub_editor) { $sub_editor->mustEncrypt = $this->mustEncrypt; } $apply_xactions = $this->didApplyHeraldRules($object, $adapter, $xscript); assert_instances_of($apply_xactions, 'PhabricatorApplicationTransaction'); $queue_xactions = $adapter->getQueuedTransactions(); return array_merge( array_values($apply_xactions), array_values($queue_xactions)); } protected function didApplyHeraldRules( PhabricatorLiskDAO $object, HeraldAdapter $adapter, HeraldTranscript $transcript) { return array(); } /* -( Custom Fields )------------------------------------------------------ */ /** * @task customfield */ private function getCustomFieldForTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { $field_key = $xaction->getMetadataValue('customfield:key'); if (!$field_key) { throw new Exception( pht( "Custom field transaction has no '%s'!", 'customfield:key')); } $field = PhabricatorCustomField::getObjectField( $object, PhabricatorCustomField::ROLE_APPLICATIONTRANSACTIONS, $field_key); if (!$field) { throw new Exception( pht( "Custom field transaction has invalid '%s'; field '%s' ". "is disabled or does not exist.", 'customfield:key', $field_key)); } if (!$field->shouldAppearInApplicationTransactions()) { throw new Exception( pht( "Custom field transaction '%s' does not implement ". "integration for %s.", $field_key, 'ApplicationTransactions')); } $field->setViewer($this->getActor()); return $field; } /* -( Files )-------------------------------------------------------------- */ /** * Extract the PHIDs of any files which these transactions attach. * * @task files */ private function extractFilePHIDs( PhabricatorLiskDAO $object, array $xactions) { $changes = $this->getRemarkupChanges($xactions); $blocks = mpull($changes, 'getNewValue'); $phids = array(); if ($blocks) { $phids[] = PhabricatorMarkupEngine::extractFilePHIDsFromEmbeddedFiles( $this->getActor(), $blocks); } foreach ($xactions as $xaction) { $type = $xaction->getTransactionType(); $xtype = $this->getModularTransactionType($type); if ($xtype) { $phids[] = $xtype->extractFilePHIDs($object, $xaction->getNewValue()); } else { $phids[] = $this->extractFilePHIDsFromCustomTransaction( $object, $xaction); } } $phids = array_unique(array_filter(array_mergev($phids))); if (!$phids) { return array(); } // Only let a user attach files they can actually see, since this would // otherwise let you access any file by attaching it to an object you have // view permission on. $files = id(new PhabricatorFileQuery()) ->setViewer($this->getActor()) ->withPHIDs($phids) ->execute(); return mpull($files, 'getPHID'); } /** * @task files */ protected function extractFilePHIDsFromCustomTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { return array(); } /** * @task files */ private function attachFiles( PhabricatorLiskDAO $object, array $file_phids) { if (!$file_phids) { return; } $editor = new PhabricatorEdgeEditor(); $src = $object->getPHID(); $type = PhabricatorObjectHasFileEdgeType::EDGECONST; foreach ($file_phids as $dst) { $editor->addEdge($src, $type, $dst); } $editor->save(); } private function applyInverseEdgeTransactions( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction, $inverse_type) { $old = $xaction->getOldValue(); $new = $xaction->getNewValue(); $add = array_keys(array_diff_key($new, $old)); $rem = array_keys(array_diff_key($old, $new)); $add = array_fuse($add); $rem = array_fuse($rem); $all = $add + $rem; $nodes = id(new PhabricatorObjectQuery()) ->setViewer($this->requireActor()) ->withPHIDs($all) ->execute(); $object_phid = $object->getPHID(); foreach ($nodes as $node) { if (!($node instanceof PhabricatorApplicationTransactionInterface)) { continue; } if ($node instanceof PhabricatorUser) { // TODO: At least for now, don't record inverse edge transactions // for users (for example, "alincoln joined project X"): Feed fills // this role instead. continue; } $node_phid = $node->getPHID(); $editor = $node->getApplicationTransactionEditor(); $template = $node->getApplicationTransactionTemplate(); // See T13082. We have to build these transactions with synthetic values // because we've already applied the actual edit to the edge database // table. If we try to apply this transaction naturally, it will no-op // itself because it doesn't have any effect. $edge_query = id(new PhabricatorEdgeQuery()) ->withSourcePHIDs(array($node_phid)) ->withEdgeTypes(array($inverse_type)); $edge_query->execute(); $edge_phids = $edge_query->getDestinationPHIDs(); $edge_phids = array_fuse($edge_phids); $new_phids = $edge_phids; $old_phids = $edge_phids; if (isset($add[$node_phid])) { unset($old_phids[$object_phid]); } else { $old_phids[$object_phid] = $object_phid; } $template ->setTransactionType($xaction->getTransactionType()) ->setMetadataValue('edge:type', $inverse_type) ->setOldValue($old_phids) ->setNewValue($new_phids); $editor = $this->newSubEditor($editor) ->setContinueOnNoEffect(true) ->setContinueOnMissingFields(true) ->setIsInverseEdgeEditor(true); $editor->applyTransactions($node, array($template)); } } /* -( Workers )------------------------------------------------------------ */ /** * Load any object state which is required to publish transactions. * * This hook is invoked in the main process before we compute data related * to publishing transactions (like email "To" and "CC" lists), and again in * the worker before publishing occurs. * * @return object Publishable object. * @task workers */ protected function willPublish(PhabricatorLiskDAO $object, array $xactions) { return $object; } /** * Convert the editor state to a serializable dictionary which can be passed * to a worker. * * This data will be loaded with @{method:loadWorkerState} in the worker. * * @return dict Serializable editor state. * @task workers */ - final private function getWorkerState() { + private function getWorkerState() { $state = array(); foreach ($this->getAutomaticStateProperties() as $property) { $state[$property] = $this->$property; } $custom_state = $this->getCustomWorkerState(); $custom_encoding = $this->getCustomWorkerStateEncoding(); $state += array( 'excludeMailRecipientPHIDs' => $this->getExcludeMailRecipientPHIDs(), 'custom' => $this->encodeStateForStorage($custom_state, $custom_encoding), 'custom.encoding' => $custom_encoding, ); return $state; } /** * Hook; return custom properties which need to be passed to workers. * * @return dict Custom properties. * @task workers */ protected function getCustomWorkerState() { return array(); } /** * Hook; return storage encoding for custom properties which need to be * passed to workers. * * This primarily allows binary data to be passed to workers and survive * JSON encoding. * * @return dict Property encodings. * @task workers */ protected function getCustomWorkerStateEncoding() { return array(); } /** * Load editor state using a dictionary emitted by @{method:getWorkerState}. * * This method is used to load state when running worker operations. * * @param dict Editor state, from @{method:getWorkerState}. * @return this * @task workers */ final public function loadWorkerState(array $state) { foreach ($this->getAutomaticStateProperties() as $property) { $this->$property = idx($state, $property); } $exclude = idx($state, 'excludeMailRecipientPHIDs', array()); $this->setExcludeMailRecipientPHIDs($exclude); $custom_state = idx($state, 'custom', array()); $custom_encodings = idx($state, 'custom.encoding', array()); $custom = $this->decodeStateFromStorage($custom_state, $custom_encodings); $this->loadCustomWorkerState($custom); return $this; } /** * Hook; set custom properties on the editor from data emitted by * @{method:getCustomWorkerState}. * * @param dict Custom state, * from @{method:getCustomWorkerState}. * @return this * @task workers */ protected function loadCustomWorkerState(array $state) { return $this; } /** * Get a list of object properties which should be automatically sent to * workers in the state data. * * These properties will be automatically stored and loaded by the editor in * the worker. * * @return list List of properties. * @task workers */ private function getAutomaticStateProperties() { return array( 'parentMessageID', 'isNewObject', 'heraldEmailPHIDs', 'heraldForcedEmailPHIDs', 'heraldHeader', 'mailToPHIDs', 'mailCCPHIDs', 'feedNotifyPHIDs', 'feedRelatedPHIDs', 'feedShouldPublish', 'mailShouldSend', 'mustEncrypt', 'mailStamps', 'mailUnexpandablePHIDs', 'mailMutedPHIDs', 'webhookMap', 'silent', 'sendHistory', ); } /** * Apply encodings prior to storage. * * See @{method:getCustomWorkerStateEncoding}. * * @param map Map of values to encode. * @param map Map of encodings to apply. * @return map Map of encoded values. * @task workers */ - final private function encodeStateForStorage( + private function encodeStateForStorage( array $state, array $encodings) { foreach ($state as $key => $value) { $encoding = idx($encodings, $key); switch ($encoding) { case self::STORAGE_ENCODING_BINARY: // The mechanics of this encoding (serialize + base64) are a little // awkward, but it allows us encode arrays and still be JSON-safe // with binary data. $value = @serialize($value); if ($value === false) { throw new Exception( pht( 'Failed to serialize() value for key "%s".', $key)); } $value = base64_encode($value); if ($value === false) { throw new Exception( pht( 'Failed to base64 encode value for key "%s".', $key)); } break; } $state[$key] = $value; } return $state; } /** * Undo storage encoding applied when storing state. * * See @{method:getCustomWorkerStateEncoding}. * * @param map Map of encoded values. * @param map Map of encodings. * @return map Map of decoded values. * @task workers */ - final private function decodeStateFromStorage( + private function decodeStateFromStorage( array $state, array $encodings) { foreach ($state as $key => $value) { $encoding = idx($encodings, $key); switch ($encoding) { case self::STORAGE_ENCODING_BINARY: $value = base64_decode($value); if ($value === false) { throw new Exception( pht( 'Failed to base64_decode() value for key "%s".', $key)); } $value = unserialize($value); break; } $state[$key] = $value; } return $state; } /** * Remove conflicts from a list of projects. * * Objects aren't allowed to be tagged with multiple milestones in the same * group, nor projects such that one tag is the ancestor of any other tag. * If the list of PHIDs include mutually exclusive projects, remove the * conflicting projects. * * @param list List of project PHIDs. * @return list List with conflicts removed. */ private function applyProjectConflictRules(array $phids) { if (!$phids) { return array(); } // Overall, the last project in the list wins in cases of conflict (so when // you add something, the thing you just added sticks and removes older // values). // Beyond that, there are two basic cases: // Milestones: An object can't be in "A > Sprint 3" and "A > Sprint 4". // If multiple projects are milestones of the same parent, we only keep the // last one. // Ancestor: You can't be in "A" and "A > B". If "A > B" comes later // in the list, we remove "A" and keep "A > B". If "A" comes later, we // remove "A > B" and keep "A". // Note that it's OK to be in "A > B" and "A > C". There's only a conflict // if one project is an ancestor of another. It's OK to have something // tagged with multiple projects which share a common ancestor, so long as // they are not mutual ancestors. $viewer = PhabricatorUser::getOmnipotentUser(); $projects = id(new PhabricatorProjectQuery()) ->setViewer($viewer) ->withPHIDs(array_keys($phids)) ->execute(); $projects = mpull($projects, null, 'getPHID'); // We're going to build a map from each project with milestones to the last // milestone in the list. This last milestone is the milestone we'll keep. $milestone_map = array(); // We're going to build a set of the projects which have no descendants // later in the list. This allows us to apply both ancestor rules. $ancestor_map = array(); foreach ($phids as $phid => $ignored) { $project = idx($projects, $phid); if (!$project) { continue; } // This is the last milestone we've seen, so set it as the selection for // the project's parent. This might be setting a new value or overwriting // an earlier value. if ($project->isMilestone()) { $parent_phid = $project->getParentProjectPHID(); $milestone_map[$parent_phid] = $phid; } // Since this is the last item in the list we've examined so far, add it // to the set of projects with no later descendants. $ancestor_map[$phid] = $phid; // Remove any ancestors from the set, since this is a later descendant. foreach ($project->getAncestorProjects() as $ancestor) { $ancestor_phid = $ancestor->getPHID(); unset($ancestor_map[$ancestor_phid]); } } // Now that we've built the maps, we can throw away all the projects which // have conflicts. foreach ($phids as $phid => $ignored) { $project = idx($projects, $phid); if (!$project) { // If a PHID is invalid, we just leave it as-is. We could clean it up, // but leaving it untouched is less likely to cause collateral damage. continue; } // If this was a milestone, check if it was the last milestone from its // group in the list. If not, remove it from the list. if ($project->isMilestone()) { $parent_phid = $project->getParentProjectPHID(); if ($milestone_map[$parent_phid] !== $phid) { unset($phids[$phid]); continue; } } // If a later project in the list is a subproject of this one, it will // have removed ancestors from the map. If this project does not point // at itself in the ancestor map, it should be discarded in favor of a // subproject that comes later. if (idx($ancestor_map, $phid) !== $phid) { unset($phids[$phid]); continue; } // If a later project in the list is an ancestor of this one, it will // have added itself to the map. If any ancestor of this project points // at itself in the map, this project should be discarded in favor of // that later ancestor. foreach ($project->getAncestorProjects() as $ancestor) { $ancestor_phid = $ancestor->getPHID(); if (isset($ancestor_map[$ancestor_phid])) { unset($phids[$phid]); continue 2; } } } return $phids; } /** * When the view policy for an object is changed, scramble the secret keys * for attached files to invalidate existing URIs. */ private function scrambleFileSecrets($object) { // If this is a newly created object, we don't need to scramble anything // since it couldn't have been previously published. if ($this->getIsNewObject()) { return; } // If the object is a file itself, scramble it. if ($object instanceof PhabricatorFile) { if ($this->shouldScramblePolicy($object->getViewPolicy())) { $object->scrambleSecret(); $object->save(); } } $phid = $object->getPHID(); $attached_phids = PhabricatorEdgeQuery::loadDestinationPHIDs( $phid, PhabricatorObjectHasFileEdgeType::EDGECONST); if (!$attached_phids) { return; } $omnipotent_viewer = PhabricatorUser::getOmnipotentUser(); $files = id(new PhabricatorFileQuery()) ->setViewer($omnipotent_viewer) ->withPHIDs($attached_phids) ->execute(); foreach ($files as $file) { $view_policy = $file->getViewPolicy(); if ($this->shouldScramblePolicy($view_policy)) { $file->scrambleSecret(); $file->save(); } } } /** * Check if a policy is strong enough to justify scrambling. Objects which * are set to very open policies don't need to scramble their files, and * files with very open policies don't need to be scrambled when associated * objects change. */ private function shouldScramblePolicy($policy) { switch ($policy) { case PhabricatorPolicies::POLICY_PUBLIC: case PhabricatorPolicies::POLICY_USER: return false; } return true; } private function updateWorkboardColumns($object, $const, $old, $new) { // If an object is removed from a project, remove it from any proxy // columns for that project. This allows a task which is moved up from a // milestone to the parent to move back into the "Backlog" column on the // parent workboard. if ($const != PhabricatorProjectObjectHasProjectEdgeType::EDGECONST) { return; } // TODO: This should likely be some future WorkboardInterface. $appears_on_workboards = ($object instanceof ManiphestTask); if (!$appears_on_workboards) { return; } $removed_phids = array_keys(array_diff_key($old, $new)); if (!$removed_phids) { return; } // Find any proxy columns for the removed projects. $proxy_columns = id(new PhabricatorProjectColumnQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withProxyPHIDs($removed_phids) ->execute(); if (!$proxy_columns) { return array(); } $proxy_phids = mpull($proxy_columns, 'getPHID'); $position_table = new PhabricatorProjectColumnPosition(); $conn_w = $position_table->establishConnection('w'); queryfx( $conn_w, 'DELETE FROM %T WHERE objectPHID = %s AND columnPHID IN (%Ls)', $position_table->getTableName(), $object->getPHID(), $proxy_phids); } private function getModularTransactionTypes() { if ($this->modularTypes === null) { $template = $this->object->getApplicationTransactionTemplate(); if ($template instanceof PhabricatorModularTransaction) { $xtypes = $template->newModularTransactionTypes(); foreach ($xtypes as $key => $xtype) { $xtype = clone $xtype; $xtype->setEditor($this); $xtypes[$key] = $xtype; } } else { $xtypes = array(); } $this->modularTypes = $xtypes; } return $this->modularTypes; } private function getModularTransactionType($type) { $types = $this->getModularTransactionTypes(); return idx($types, $type); } public function getCreateObjectTitle($author, $object) { return pht('%s created this object.', $author); } public function getCreateObjectTitleForFeed($author, $object) { return pht('%s created an object: %s.', $author, $object); } /* -( Queue )-------------------------------------------------------------- */ protected function queueTransaction( PhabricatorApplicationTransaction $xaction) { $this->transactionQueue[] = $xaction; return $this; } private function flushTransactionQueue($object) { if (!$this->transactionQueue) { return; } $xactions = $this->transactionQueue; $this->transactionQueue = array(); $editor = $this->newEditorCopy(); return $editor->applyTransactions($object, $xactions); } final protected function newSubEditor( PhabricatorApplicationTransactionEditor $template = null) { $editor = $this->newEditorCopy($template); $editor->parentEditor = $this; $this->subEditors[] = $editor; return $editor; } private function newEditorCopy( PhabricatorApplicationTransactionEditor $template = null) { if ($template === null) { $template = newv(get_class($this), array()); } $editor = id(clone $template) ->setActor($this->getActor()) ->setContentSource($this->getContentSource()) ->setContinueOnNoEffect($this->getContinueOnNoEffect()) ->setContinueOnMissingFields($this->getContinueOnMissingFields()) ->setParentMessageID($this->getParentMessageID()) ->setIsSilent($this->getIsSilent()); if ($this->actingAsPHID !== null) { $editor->setActingAsPHID($this->actingAsPHID); } $editor->mustEncrypt = $this->mustEncrypt; $editor->transactionGroupID = $this->getTransactionGroupID(); return $editor; } /* -( Stamps )------------------------------------------------------------- */ public function newMailStampTemplates($object) { $actor = $this->getActor(); $templates = array(); $extensions = $this->newMailExtensions($object); foreach ($extensions as $extension) { $stamps = $extension->newMailStampTemplates($object); foreach ($stamps as $stamp) { $key = $stamp->getKey(); if (isset($templates[$key])) { throw new Exception( pht( 'Mail extension ("%s") defines a stamp template with the '. 'same key ("%s") as another template. Each stamp template '. 'must have a unique key.', get_class($extension), $key)); } $stamp->setViewer($actor); $templates[$key] = $stamp; } } return $templates; } final public function getMailStamp($key) { if (!isset($this->stampTemplates)) { throw new PhutilInvalidStateException('newMailStampTemplates'); } if (!isset($this->stampTemplates[$key])) { throw new Exception( pht( 'Editor ("%s") has no mail stamp template with provided key ("%s").', get_class($this), $key)); } return $this->stampTemplates[$key]; } private function newMailStamps($object, array $xactions) { $actor = $this->getActor(); $this->stampTemplates = $this->newMailStampTemplates($object); $extensions = $this->newMailExtensions($object); $stamps = array(); foreach ($extensions as $extension) { $extension->newMailStamps($object, $xactions); } return $this->stampTemplates; } private function newMailExtensions($object) { $actor = $this->getActor(); $all_extensions = PhabricatorMailEngineExtension::getAllExtensions(); $extensions = array(); foreach ($all_extensions as $key => $template) { $extension = id(clone $template) ->setViewer($actor) ->setEditor($this); if ($extension->supportsObject($object)) { $extensions[$key] = $extension; } } return $extensions; } protected function newAuxiliaryMail($object, array $xactions) { return array(); } private function generateMailStamps($object, $data) { if (!$data || !is_array($data)) { return null; } $templates = $this->newMailStampTemplates($object); foreach ($data as $spec) { if (!is_array($spec)) { continue; } $key = idx($spec, 'key'); if (!isset($templates[$key])) { continue; } $type = idx($spec, 'type'); if ($templates[$key]->getStampType() !== $type) { continue; } $value = idx($spec, 'value'); $templates[$key]->setValueFromDictionary($value); } $results = array(); foreach ($templates as $template) { $value = $template->getValueForRendering(); $rendered = $template->renderStamps($value); if ($rendered === null) { continue; } $rendered = (array)$rendered; foreach ($rendered as $stamp) { $results[] = $stamp; } } natcasesort($results); return $results; } public function getRemovedRecipientPHIDs() { return $this->mailRemovedPHIDs; } private function buildOldRecipientLists($object, $xactions) { // See T4776. Before we start making any changes, build a list of the old // recipients. If a change removes a user from the recipient list for an // object we still want to notify the user about that change. This allows // them to respond if they didn't want to be removed. if (!$this->shouldSendMail($object, $xactions)) { return; } $this->oldTo = $this->getMailTo($object); $this->oldCC = $this->getMailCC($object); return $this; } private function applyOldRecipientLists() { $actor_phid = $this->getActingAsPHID(); // If you took yourself off the recipient list (for example, by // unsubscribing or resigning) assume that you know what you did and // don't need to be notified. // If you just moved from "To" to "Cc" (or vice versa), you're still a // recipient so we don't need to add you back in. $map = array_fuse($this->mailToPHIDs) + array_fuse($this->mailCCPHIDs); foreach ($this->oldTo as $phid) { if ($phid === $actor_phid) { continue; } if (isset($map[$phid])) { continue; } $this->mailToPHIDs[] = $phid; $this->mailRemovedPHIDs[] = $phid; } foreach ($this->oldCC as $phid) { if ($phid === $actor_phid) { continue; } if (isset($map[$phid])) { continue; } $this->mailCCPHIDs[] = $phid; $this->mailRemovedPHIDs[] = $phid; } return $this; } private function queueWebhooks($object, array $xactions) { $hook_viewer = PhabricatorUser::getOmnipotentUser(); $webhook_map = $this->webhookMap; if (!is_array($webhook_map)) { $webhook_map = array(); } // Add any "Firehose" hooks to the list of hooks we're going to call. $firehose_hooks = id(new HeraldWebhookQuery()) ->setViewer($hook_viewer) ->withStatuses( array( HeraldWebhook::HOOKSTATUS_FIREHOSE, )) ->execute(); foreach ($firehose_hooks as $firehose_hook) { // This is "the hook itself is the reason this hook is being called", // since we're including it because it's configured as a firehose // hook. $hook_phid = $firehose_hook->getPHID(); $webhook_map[$hook_phid][] = $hook_phid; } if (!$webhook_map) { return; } // NOTE: We're going to queue calls to disabled webhooks, they'll just // immediately fail in the worker queue. This makes the behavior more // visible. $call_hooks = id(new HeraldWebhookQuery()) ->setViewer($hook_viewer) ->withPHIDs(array_keys($webhook_map)) ->execute(); foreach ($call_hooks as $call_hook) { $trigger_phids = idx($webhook_map, $call_hook->getPHID()); $request = HeraldWebhookRequest::initializeNewWebhookRequest($call_hook) ->setObjectPHID($object->getPHID()) ->setTransactionPHIDs(mpull($xactions, 'getPHID')) ->setTriggerPHIDs($trigger_phids) ->setRetryMode(HeraldWebhookRequest::RETRY_FOREVER) ->setIsSilentAction((bool)$this->getIsSilent()) ->setIsSecureAction((bool)$this->getMustEncrypt()) ->save(); $request->queueCall(); } } private function hasWarnings($object, $xaction) { // TODO: For the moment, this is a very un-modular hack to support // a small number of warnings related to draft revisions. See PHI433. if (!($object instanceof DifferentialRevision)) { return false; } $type = $xaction->getTransactionType(); // TODO: This doesn't warn for inlines in Audit, even though they have // the same overall workflow. if ($type === DifferentialTransaction::TYPE_INLINE) { return (bool)$xaction->getComment()->getAttribute('editing', false); } if (!$object->isDraft()) { return false; } if ($type != PhabricatorTransactions::TYPE_SUBSCRIBERS) { return false; } // We're only going to raise a warning if the transaction adds subscribers // other than the acting user. (This implementation is clumsy because the // code runs before a lot of normalization occurs.) $old = $this->getTransactionOldValue($object, $xaction); $new = $this->getPHIDTransactionNewValue($xaction, $old); $old = array_fuse($old); $new = array_fuse($new); $add = array_diff_key($new, $old); unset($add[$this->getActingAsPHID()]); if (!$add) { return false; } return true; } private function buildHistoryMail(PhabricatorLiskDAO $object) { $viewer = $this->requireActor(); $recipient_phid = $this->getActingAsPHID(); // Load every transaction so we can build a mail message with a complete // history for the object. $query = PhabricatorApplicationTransactionQuery::newQueryForObject($object); $xactions = $query ->setViewer($viewer) ->withObjectPHIDs(array($object->getPHID())) ->execute(); $xactions = array_reverse($xactions); $mail_messages = $this->buildMailWithRecipients( $object, $xactions, array($recipient_phid), array(), array()); $mail = head($mail_messages); // Since the user explicitly requested "!history", force delivery of this // message regardless of their other mail settings. $mail->setForceDelivery(true); return $mail; } public function newAutomaticInlineTransactions( PhabricatorLiskDAO $object, $transaction_type, PhabricatorCursorPagedPolicyAwareQuery $query_template) { $actor = $this->getActor(); $inlines = id(clone $query_template) ->setViewer($actor) ->withObjectPHIDs(array($object->getPHID())) ->withPublishableComments(true) ->needAppliedDrafts(true) ->needReplyToComments(true) ->execute(); $inlines = msort($inlines, 'getID'); $xactions = array(); foreach ($inlines as $key => $inline) { $xactions[] = $object->getApplicationTransactionTemplate() ->setTransactionType($transaction_type) ->attachComment($inline); } $state_xaction = $this->newInlineStateTransaction( $object, $query_template); if ($state_xaction) { $xactions[] = $state_xaction; } return $xactions; } protected function newInlineStateTransaction( PhabricatorLiskDAO $object, PhabricatorCursorPagedPolicyAwareQuery $query_template) { $actor_phid = $this->getActingAsPHID(); $author_phid = $object->getAuthorPHID(); $actor_is_author = ($actor_phid == $author_phid); $state_map = PhabricatorTransactions::getInlineStateMap(); $inline_query = id(clone $query_template) ->setViewer($this->getActor()) ->withObjectPHIDs(array($object->getPHID())) ->withFixedStates(array_keys($state_map)) ->withPublishableComments(true); if ($actor_is_author) { $inline_query->withPublishedComments(true); } $inlines = $inline_query->execute(); if (!$inlines) { return null; } $old_value = mpull($inlines, 'getFixedState', 'getPHID'); $new_value = array(); foreach ($old_value as $key => $state) { $new_value[$key] = $state_map[$state]; } // See PHI995. Copy some information about the inlines into the transaction // so we can tailor rendering behavior. In particular, we don't want to // render transactions about users marking their own inlines as "Done". $inline_details = array(); foreach ($inlines as $inline) { $inline_details[$inline->getPHID()] = array( 'authorPHID' => $inline->getAuthorPHID(), ); } return $object->getApplicationTransactionTemplate() ->setTransactionType(PhabricatorTransactions::TYPE_INLINESTATE) ->setIgnoreOnNoEffect(true) ->setMetadataValue('inline.details', $inline_details) ->setOldValue($old_value) ->setNewValue($new_value); } private function requireMFA(PhabricatorLiskDAO $object, array $xactions) { $actor = $this->getActor(); // Let omnipotent editors skip MFA. This is mostly aimed at scripts. if ($actor->isOmnipotent()) { return; } $editor_class = get_class($this); $object_phid = $object->getPHID(); if ($object_phid) { $workflow_key = sprintf( 'editor(%s).phid(%s)', $editor_class, $object_phid); } else { $workflow_key = sprintf( 'editor(%s).new()', $editor_class); } $request = $this->getRequest(); if ($request === null) { $source_type = $this->getContentSource()->getSourceTypeConstant(); $conduit_type = PhabricatorConduitContentSource::SOURCECONST; $is_conduit = ($source_type === $conduit_type); if ($is_conduit) { throw new Exception( pht( 'This transaction group requires MFA to apply, but you can not '. 'provide an MFA response via Conduit. Edit this object via the '. 'web UI.')); } else { throw new Exception( pht( 'This transaction group requires MFA to apply, but the Editor was '. 'not configured with a Request. This workflow can not perform an '. 'MFA check.')); } } $cancel_uri = $this->getCancelURI(); if ($cancel_uri === null) { throw new Exception( pht( 'This transaction group requires MFA to apply, but the Editor was '. 'not configured with a Cancel URI. This workflow can not perform '. 'an MFA check.')); } $token = id(new PhabricatorAuthSessionEngine()) ->setWorkflowKey($workflow_key) ->requireHighSecurityToken($actor, $request, $cancel_uri); if (!$token->getIsUnchallengedToken()) { foreach ($xactions as $xaction) { $xaction->setIsMFATransaction(true); } } } private function newMFATransactions( PhabricatorLiskDAO $object, array $xactions) { $has_engine = ($object instanceof PhabricatorEditEngineMFAInterface); if ($has_engine) { $engine = PhabricatorEditEngineMFAEngine::newEngineForObject($object) ->setViewer($this->getActor()); $require_mfa = $engine->shouldRequireMFA(); $try_mfa = $engine->shouldTryMFA(); } else { $require_mfa = false; $try_mfa = false; } // If the user is mentioning an MFA object on another object or creating // a relationship like "parent" or "child" to this object, we always // allow the edit to move forward without requiring MFA. if ($this->getIsInverseEdgeEditor()) { return $xactions; } if (!$require_mfa) { // If the object hasn't already opted into MFA, see if any of the // transactions want it. if (!$try_mfa) { foreach ($xactions as $xaction) { $type = $xaction->getTransactionType(); $xtype = $this->getModularTransactionType($type); if ($xtype) { $xtype = clone $xtype; $xtype->setStorage($xaction); if ($xtype->shouldTryMFA($object, $xaction)) { $try_mfa = true; break; } } } } if ($try_mfa) { $this->setShouldRequireMFA(true); } return $xactions; } $type_mfa = PhabricatorTransactions::TYPE_MFA; $has_mfa = false; foreach ($xactions as $xaction) { if ($xaction->getTransactionType() === $type_mfa) { $has_mfa = true; break; } } if ($has_mfa) { return $xactions; } $template = $object->getApplicationTransactionTemplate(); $mfa_xaction = id(clone $template) ->setTransactionType($type_mfa) ->setNewValue(true); array_unshift($xactions, $mfa_xaction); return $xactions; } private function getTitleForTextMail( PhabricatorApplicationTransaction $xaction) { $type = $xaction->getTransactionType(); $xtype = $this->getModularTransactionType($type); if ($xtype) { $xtype = clone $xtype; $xtype->setStorage($xaction); $comment = $xtype->getTitleForTextMail(); if ($comment !== false) { return $comment; } } return $xaction->getTitleForTextMail(); } private function getTitleForHTMLMail( PhabricatorApplicationTransaction $xaction) { $type = $xaction->getTransactionType(); $xtype = $this->getModularTransactionType($type); if ($xtype) { $xtype = clone $xtype; $xtype->setStorage($xaction); $comment = $xtype->getTitleForHTMLMail(); if ($comment !== false) { return $comment; } } return $xaction->getTitleForHTMLMail(); } private function getBodyForTextMail( PhabricatorApplicationTransaction $xaction) { $type = $xaction->getTransactionType(); $xtype = $this->getModularTransactionType($type); if ($xtype) { $xtype = clone $xtype; $xtype->setStorage($xaction); $comment = $xtype->getBodyForTextMail(); if ($comment !== false) { return $comment; } } return $xaction->getBodyForMail(); } private function isLockOverrideTransaction( PhabricatorApplicationTransaction $xaction) { // See PHI1209. When an object is locked, certain types of transactions // can still be applied without requiring a policy check, like subscribing // or unsubscribing. We don't want these transactions to show the "Lock // Override" icon in the transaction timeline. // We could test if a transaction did no direct policy checks, but it may // have done additional policy checks during validation, so this is not a // reliable test (and could cause false negatives, where edits which did // override a lock are not marked properly). // For now, do this in a narrow way and just check against a hard-coded // list of non-override transaction situations. Some day, this should // likely be modularized. // Inverse edge edits don't interact with locks. if ($this->getIsInverseEdgeEditor()) { return false; } // For now, all edits other than subscribes always override locks. $type = $xaction->getTransactionType(); if ($type !== PhabricatorTransactions::TYPE_SUBSCRIBERS) { return true; } // Subscribes override locks if they affect any users other than the // acting user. $acting_phid = $this->getActingAsPHID(); $old = array_fuse($xaction->getOldValue()); $new = array_fuse($xaction->getNewValue()); $add = array_diff_key($new, $old); $rem = array_diff_key($old, $new); $all = $add + $rem; foreach ($all as $phid) { if ($phid !== $acting_phid) { return true; } } return false; } /* -( Extensions )--------------------------------------------------------- */ private function validateTransactionsWithExtensions( PhabricatorLiskDAO $object, array $xactions) { $errors = array(); $extensions = $this->getEditorExtensions(); foreach ($extensions as $extension) { $extension_errors = $extension ->setObject($object) ->validateTransactions($object, $xactions); assert_instances_of( $extension_errors, 'PhabricatorApplicationTransactionValidationError'); $errors[] = $extension_errors; } return array_mergev($errors); } private function getEditorExtensions() { if ($this->extensions === null) { $this->extensions = $this->newEditorExtensions(); } return $this->extensions; } private function newEditorExtensions() { $extensions = PhabricatorEditorExtension::getAllExtensions(); $actor = $this->getActor(); $object = $this->object; foreach ($extensions as $key => $extension) { $extension = id(clone $extension) ->setViewer($actor) ->setEditor($this) ->setObject($object); if (!$extension->supportsObject($this, $object)) { unset($extensions[$key]); continue; } $extensions[$key] = $extension; } return $extensions; } } diff --git a/src/infrastructure/query/policy/PhabricatorCursorPagedPolicyAwareQuery.php b/src/infrastructure/query/policy/PhabricatorCursorPagedPolicyAwareQuery.php index cb3a62c6ab..34a8e1813c 100644 --- a/src/infrastructure/query/policy/PhabricatorCursorPagedPolicyAwareQuery.php +++ b/src/infrastructure/query/policy/PhabricatorCursorPagedPolicyAwareQuery.php @@ -1,3262 +1,3262 @@ getID(); } protected function newInternalCursorFromExternalCursor($cursor) { $viewer = $this->getViewer(); $query = newv(get_class($this), array()); $query ->setParentQuery($this) ->setViewer($viewer); // We're copying our order vector to the subquery so that the subquery // knows it should generate any supplemental information required by the // ordering. // For example, Phriction documents may be ordered by title, but the title // isn't a column in the "document" table: the query must JOIN the // "content" table to perform the ordering. Passing the ordering to the // subquery tells it that we need it to do that JOIN and attach relevant // paging information to the internal cursor object. // We only expect to load a single result, so the actual result order does // not matter. We only want the internal cursor for that result to look // like a cursor this parent query would generate. $query->setOrderVector($this->getOrderVector()); $this->applyExternalCursorConstraintsToQuery($query, $cursor); // If we have a Ferret fulltext query, copy it to the subquery so that we // generate ranking columns appropriately, and compute the correct object // ranking score for the current query. if ($this->ferretEngine) { $query->withFerretConstraint($this->ferretEngine, $this->ferretTokens); } // We're executing the subquery normally to make sure the viewer can // actually see the object, and that it's a completely valid object which // passes all filtering and policy checks. You aren't allowed to use an // object you can't see as a cursor, since this can leak information. $result = $query->executeOne(); if (!$result) { $this->throwCursorException( pht( 'Cursor "%s" does not identify a valid object in query "%s".', $cursor, get_class($this))); } // Now that we made sure the viewer can actually see the object the // external cursor identifies, return the internal cursor the query // generated as a side effect while loading the object. return $query->getInternalCursorObject(); } final protected function throwCursorException($message) { throw new PhabricatorInvalidQueryCursorException($message); } protected function applyExternalCursorConstraintsToQuery( PhabricatorCursorPagedPolicyAwareQuery $subquery, $cursor) { $subquery->withIDs(array($cursor)); } protected function newPagingMapFromCursorObject( PhabricatorQueryCursor $cursor, array $keys) { $object = $cursor->getObject(); return $this->newPagingMapFromPartialObject($object); } protected function newPagingMapFromPartialObject($object) { return array( 'id' => (int)$object->getID(), ); } - final private function getExternalCursorStringForResult($object) { + private function getExternalCursorStringForResult($object) { $cursor = $this->newExternalCursorStringForResult($object); if (!is_string($cursor)) { throw new Exception( pht( 'Expected "newExternalCursorStringForResult()" in class "%s" to '. 'return a string, but got "%s".', get_class($this), phutil_describe_type($cursor))); } return $cursor; } final protected function getExternalCursorString() { return $this->externalCursorString; } - final private function setExternalCursorString($external_cursor) { + private function setExternalCursorString($external_cursor) { $this->externalCursorString = $external_cursor; return $this; } final protected function getIsQueryOrderReversed() { return $this->isQueryOrderReversed; } final protected function setIsQueryOrderReversed($is_reversed) { $this->isQueryOrderReversed = $is_reversed; return $this; } - final private function getInternalCursorObject() { + private function getInternalCursorObject() { return $this->internalCursorObject; } - final private function setInternalCursorObject( + private function setInternalCursorObject( PhabricatorQueryCursor $cursor) { $this->internalCursorObject = $cursor; return $this; } - final private function getInternalCursorFromExternalCursor( + private function getInternalCursorFromExternalCursor( $cursor_string) { $cursor_object = $this->newInternalCursorFromExternalCursor($cursor_string); if (!($cursor_object instanceof PhabricatorQueryCursor)) { throw new Exception( pht( 'Expected "newInternalCursorFromExternalCursor()" to return an '. 'object of class "PhabricatorQueryCursor", but got "%s" (in '. 'class "%s").', phutil_describe_type($cursor_object), get_class($this))); } return $cursor_object; } - final private function getPagingMapFromCursorObject( + private function getPagingMapFromCursorObject( PhabricatorQueryCursor $cursor, array $keys) { $map = $this->newPagingMapFromCursorObject($cursor, $keys); if (!is_array($map)) { throw new Exception( pht( 'Expected "newPagingMapFromCursorObject()" to return a map of '. 'paging values, but got "%s" (in class "%s").', phutil_describe_type($map), get_class($this))); } if ($this->supportsFerretEngine()) { if ($this->hasFerretOrder()) { $map += array( 'rank' => $cursor->getRawRowProperty(self::FULLTEXT_RANK), 'fulltext-modified' => $cursor->getRawRowProperty(self::FULLTEXT_MODIFIED), 'fulltext-created' => $cursor->getRawRowProperty(self::FULLTEXT_CREATED), ); } } foreach ($keys as $key) { if (!array_key_exists($key, $map)) { throw new Exception( pht( 'Map returned by "newPagingMapFromCursorObject()" in class "%s" '. 'omits required key "%s".', get_class($this), $key)); } } return $map; } final protected function nextPage(array $page) { if (!$page) { return; } $cursor = id(new PhabricatorQueryCursor()) ->setObject(last($page)); if ($this->rawCursorRow) { $cursor->setRawRow($this->rawCursorRow); } $this->setInternalCursorObject($cursor); } final public function getFerretMetadata() { if (!$this->supportsFerretEngine()) { throw new Exception( pht( 'Unable to retrieve Ferret engine metadata, this class ("%s") does '. 'not support the Ferret engine.', get_class($this))); } return $this->ferretMetadata; } protected function loadStandardPage(PhabricatorLiskDAO $table) { $rows = $this->loadStandardPageRows($table); return $table->loadAllFromArray($rows); } protected function loadStandardPageRows(PhabricatorLiskDAO $table) { $conn = $table->establishConnection('r'); return $this->loadStandardPageRowsWithConnection( $conn, $table->getTableName()); } protected function loadStandardPageRowsWithConnection( AphrontDatabaseConnection $conn, $table_name) { $query = $this->buildStandardPageQuery($conn, $table_name); $rows = queryfx_all($conn, '%Q', $query); $rows = $this->didLoadRawRows($rows); return $rows; } protected function buildStandardPageQuery( AphrontDatabaseConnection $conn, $table_name) { $table_alias = $this->getPrimaryTableAlias(); if ($table_alias === null) { $table_alias = qsprintf($conn, ''); } else { $table_alias = qsprintf($conn, '%T', $table_alias); } return qsprintf( $conn, '%Q FROM %T %Q %Q %Q %Q %Q %Q %Q', $this->buildSelectClause($conn), $table_name, $table_alias, $this->buildJoinClause($conn), $this->buildWhereClause($conn), $this->buildGroupClause($conn), $this->buildHavingClause($conn), $this->buildOrderClause($conn), $this->buildLimitClause($conn)); } protected function didLoadRawRows(array $rows) { $this->rawCursorRow = last($rows); if ($this->ferretEngine) { foreach ($rows as $row) { $phid = $row['phid']; $metadata = id(new PhabricatorFerretMetadata()) ->setPHID($phid) ->setEngine($this->ferretEngine) ->setRelevance(idx($row, self::FULLTEXT_RANK)); $this->ferretMetadata[$phid] = $metadata; unset($row[self::FULLTEXT_RANK]); unset($row[self::FULLTEXT_MODIFIED]); unset($row[self::FULLTEXT_CREATED]); } } return $rows; } final protected function buildLimitClause(AphrontDatabaseConnection $conn) { if ($this->shouldLimitResults()) { $limit = $this->getRawResultLimit(); if ($limit) { return qsprintf($conn, 'LIMIT %d', $limit); } } return qsprintf($conn, ''); } protected function shouldLimitResults() { return true; } final protected function didLoadResults(array $results) { if ($this->getIsQueryOrderReversed()) { $results = array_reverse($results, $preserve_keys = true); } return $results; } final public function newIterator() { return new PhabricatorQueryIterator($this); } final public function executeWithCursorPager(AphrontCursorPagerView $pager) { $limit = $pager->getPageSize(); $this->setLimit($limit + 1); if (strlen($pager->getAfterID())) { $this->setExternalCursorString($pager->getAfterID()); } else if ($pager->getBeforeID()) { $this->setExternalCursorString($pager->getBeforeID()); $this->setIsQueryOrderReversed(true); } $results = $this->execute(); $count = count($results); $sliced_results = $pager->sliceResults($results); if ($sliced_results) { // If we have results, generate external-facing cursors from the visible // results. This stops us from leaking any internal details about objects // which we loaded but which were not visible to the viewer. if ($pager->getBeforeID() || ($count > $limit)) { $last_object = last($sliced_results); $cursor = $this->getExternalCursorStringForResult($last_object); $pager->setNextPageID($cursor); } if ($pager->getAfterID() || ($pager->getBeforeID() && ($count > $limit))) { $head_object = head($sliced_results); $cursor = $this->getExternalCursorStringForResult($head_object); $pager->setPrevPageID($cursor); } } 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($conn, $parts); } /** * @task clauses */ protected function buildSelectClauseParts(AphrontDatabaseConnection $conn) { $select = array(); $alias = $this->getPrimaryTableAlias(); if ($alias) { $select[] = qsprintf($conn, '%T.*', $alias); } else { $select[] = qsprintf($conn, '*'); } $select[] = $this->buildEdgeLogicSelectClause($conn); $select[] = $this->buildFerretSelectClause($conn); return $select; } /** * @task clauses */ protected function buildJoinClause(AphrontDatabaseConnection $conn) { $joins = $this->buildJoinClauseParts($conn); return $this->formatJoinClause($conn, $joins); } /** * @task clauses */ protected function buildJoinClauseParts(AphrontDatabaseConnection $conn) { $joins = array(); $joins[] = $this->buildEdgeLogicJoinClause($conn); $joins[] = $this->buildApplicationSearchJoinClause($conn); $joins[] = $this->buildNgramsJoinClause($conn); $joins[] = $this->buildFerretJoinClause($conn); return $joins; } /** * @task clauses */ protected function buildWhereClause(AphrontDatabaseConnection $conn) { $where = $this->buildWhereClauseParts($conn); return $this->formatWhereClause($conn, $where); } /** * @task clauses */ protected function buildWhereClauseParts(AphrontDatabaseConnection $conn) { $where = array(); $where[] = $this->buildPagingWhereClause($conn); $where[] = $this->buildEdgeLogicWhereClause($conn); $where[] = $this->buildSpacesWhereClause($conn); $where[] = $this->buildNgramsWhereClause($conn); $where[] = $this->buildFerretWhereClause($conn); $where[] = $this->buildApplicationSearchWhereClause($conn); return $where; } /** * @task clauses */ protected function buildHavingClause(AphrontDatabaseConnection $conn) { $having = $this->buildHavingClauseParts($conn); $having[] = $this->buildPagingHavingClause($conn); return $this->formatHavingClause($conn, $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 qsprintf($conn, ''); } return qsprintf( $conn, 'GROUP BY %Q', $this->getApplicationSearchObjectPHIDColumn($conn)); } /** * @task clauses */ protected function shouldGroupQueryResultRows() { if ($this->shouldGroupEdgeLogicResultRows()) { return true; } if ($this->getApplicationSearchMayJoinMultipleRows()) { return true; } if ($this->shouldGroupNgramResultRows()) { return true; } if ($this->shouldGroupFerretResultRows()) { return true; } return false; } /* -( Paging )------------------------------------------------------------- */ private function buildPagingWhereClause(AphrontDatabaseConnection $conn) { if ($this->shouldPageWithHavingClause()) { return null; } return $this->buildPagingClause($conn); } private function buildPagingHavingClause(AphrontDatabaseConnection $conn) { if (!$this->shouldPageWithHavingClause()) { return null; } return $this->buildPagingClause($conn); } private function shouldPageWithHavingClause() { // If any of the paging conditions reference dynamic columns, we need to // put the paging conditions in a "HAVING" clause instead of a "WHERE" // clause. // For example, this happens when paging on the Ferret "rank" column, // since the "rank" value is computed dynamically in the SELECT statement. $orderable = $this->getOrderableColumns(); $vector = $this->getOrderVector(); foreach ($vector as $order) { $key = $order->getOrderKey(); $column = $orderable[$key]; if (!empty($column['having'])) { return true; } } return false; } /** * @task paging */ protected function buildPagingClause(AphrontDatabaseConnection $conn) { $orderable = $this->getOrderableColumns(); $vector = $this->getQueryableOrderVector(); // If we don't have a cursor object yet, it means we're trying to load // the first result page. We may need to build a cursor object from the // external string, or we may not need a paging clause yet. $cursor_object = $this->getInternalCursorObject(); if (!$cursor_object) { $external_cursor = $this->getExternalCursorString(); if ($external_cursor !== null) { $cursor_object = $this->getInternalCursorFromExternalCursor( $external_cursor); } } // If we still don't have a cursor object, this is the first result page // and we aren't paging it. We don't need to build a paging clause. if (!$cursor_object) { return qsprintf($conn, ''); } $reversed = $this->getIsQueryOrderReversed(); $keys = array(); foreach ($vector as $order) { $keys[] = $order->getOrderKey(); } $keys = array_fuse($keys); $value_map = $this->getPagingMapFromCursorObject( $cursor_object, $keys); $columns = array(); foreach ($vector as $order) { $key = $order->getOrderKey(); $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, )); } /** * 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 construction 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', 'requires-ferret' => 'optional bool', 'having' => 'optional bool', )); } 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 ? qsprintf($conn, '>') : qsprintf($conn, '<'), $value); } if ($parts) { $clause[] = qsprintf($conn, '%LO', $parts); } if ($clause) { $clauses[] = qsprintf($conn, '%LA', $clause); } if ($value === null) { $accumulated[] = qsprintf( $conn, '%Q IS NULL', $field); } else { $accumulated[] = qsprintf( $conn, '%Q = %Q', $field, $value); } } if ($clauses) { return qsprintf($conn, '%LO', $clauses); } return qsprintf($conn, ''); } /* -( 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()), ); } } if ($this->supportsFerretEngine()) { $orders['relevance'] = array( 'vector' => array('rank', 'fulltext-modified', 'id'), 'name' => pht('Relevance'), ); } 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, ); } } if ($this->supportsFerretEngine()) { $columns['rank'] = array( 'table' => null, 'column' => self::FULLTEXT_RANK, 'type' => 'int', 'requires-ferret' => true, 'having' => true, ); $columns['fulltext-created'] = array( 'table' => null, 'column' => self::FULLTEXT_CREATED, 'type' => 'int', 'requires-ferret' => true, ); $columns['fulltext-modified'] = array( 'table' => null, 'column' => self::FULLTEXT_MODIFIED, 'type' => 'int', 'requires-ferret' => true, ); } $cache->setKey($cache_key, $columns); return $columns; } /** * @task order */ final protected function buildOrderClause( AphrontDatabaseConnection $conn, $for_union = false) { $orderable = $this->getOrderableColumns(); $vector = $this->getQueryableOrderVector(); $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, $for_union); } /** * @task order */ private function getQueryableOrderVector() { $vector = $this->getOrderVector(); $orderable = $this->getOrderableColumns(); $keep = array(); foreach ($vector as $order) { $column = $orderable[$order->getOrderKey()]; // If this is a Ferret fulltext column but the query doesn't actually // have a fulltext query, we'll skip most of the Ferret stuff and won't // actually have the columns in the result set. Just skip them. if (!empty($column['requires-ferret'])) { if (!$this->getFerretTokens()) { continue; } } $keep[] = $order->getAsScalar(); } return PhabricatorQueryOrderVector::newFromVector($keep); } /** * @task order */ protected function formatOrderClause( AphrontDatabaseConnection $conn, array $parts, $for_union = false) { $is_query_reversed = $this->getIsQueryOrderReversed(); $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'); // When we're building an ORDER BY clause for a sequence of UNION // statements, we can't refer to tables from the subqueries. if ($for_union) { $table = null; } $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 %LQ', $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) { $values = (array)$value; $data_values = array(); $constraint_values = array(); foreach ($values as $value) { if ($value instanceof PhabricatorQueryConstraint) { $constraint_values[] = $value; } else { $data_values[] = $value; } } $alias = 'appsearch_'.count($this->applicationSearchConstraints); $this->applicationSearchConstraints[] = array( 'type' => $index->getIndexValueType(), 'cond' => '=', 'table' => $index->getTableName(), 'index' => $index->getIndexKey(), 'alias' => $alias, 'value' => $values, 'data' => $data_values, 'constraints' => $constraint_values, ); 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')); } $alias = 'appsearch_'.count($this->applicationSearchConstraints); $this->applicationSearchConstraints[] = array( 'type' => $index->getIndexValueType(), 'cond' => 'range', 'table' => $index->getTableName(), 'index' => $index->getIndexKey(), 'alias' => $alias, 'value' => array($min, $max), 'data' => null, 'constraints' => null, ); 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. * * @param AphrontDatabaseConnection Connection executing queries. * @return PhutilQueryString Column name. * @task appsearch */ protected function getApplicationSearchObjectPHIDColumn( AphrontDatabaseConnection $conn) { if ($this->getPrimaryTableAlias()) { return qsprintf($conn, '%T.phid', $this->getPrimaryTableAlias()); } else { return qsprintf($conn, '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($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) { if ($this->getApplicationSearchMayJoinMultipleRows()) { return qsprintf( $conn, 'GROUP BY %Q', $this->getApplicationSearchObjectPHIDColumn($conn)); } else { return qsprintf($conn, ''); } } /** * 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) { $joins = array(); foreach ($this->applicationSearchConstraints as $key => $constraint) { $table = $constraint['table']; $alias = $constraint['alias']; $index = $constraint['index']; $cond = $constraint['cond']; $phid_column = $this->getApplicationSearchObjectPHIDColumn($conn); switch ($cond) { case '=': // Figure out whether we need to do a LEFT JOIN or not. We need to // LEFT JOIN if we're going to select "IS NULL" rows. $join_type = qsprintf($conn, 'JOIN'); foreach ($constraint['constraints'] as $query_constraint) { $op = $query_constraint->getOperator(); if ($op === PhabricatorQueryConstraint::OPERATOR_NULL) { $join_type = qsprintf($conn, 'LEFT JOIN'); break; } } $joins[] = qsprintf( $conn, '%Q %T %T ON %T.objectPHID = %Q AND %T.indexKey = %s', $join_type, $table, $alias, $alias, $phid_column, $alias, $index); 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, '%T.indexValue <= %d', $alias, $max); } else if ($max === null) { $constraint_clause = qsprintf( $conn, '%T.indexValue >= %d', $alias, $min); } else { $constraint_clause = qsprintf( $conn, '%T.indexValue BETWEEN %d AND %d', $alias, $min, $max); } $joins[] = qsprintf( $conn, '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($conn); $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, 'LEFT JOIN %T %T ON %T.objectPHID = %Q AND %T.indexKey = %s', $table, $alias, $alias, $phid_column, $alias, $key); } if ($joins) { return qsprintf($conn, '%LJ', $joins); } else { return qsprintf($conn, ''); } } /** * Construct a WHERE clause appropriate for applying ApplicationSearch * constraints. * * @param AphrontDatabaseConnection Connection executing the query. * @return list Where clause parts. * @task appsearch */ protected function buildApplicationSearchWhereClause( AphrontDatabaseConnection $conn) { $where = array(); foreach ($this->applicationSearchConstraints as $key => $constraint) { $alias = $constraint['alias']; $cond = $constraint['cond']; $type = $constraint['type']; $data_values = $constraint['data']; $constraint_values = $constraint['constraints']; $constraint_parts = array(); switch ($cond) { case '=': if ($data_values) { switch ($type) { case 'string': $constraint_parts[] = qsprintf( $conn, '%T.indexValue IN (%Ls)', $alias, $data_values); break; case 'int': $constraint_parts[] = qsprintf( $conn, '%T.indexValue IN (%Ld)', $alias, $data_values); break; default: throw new Exception(pht('Unknown index type "%s"!', $type)); } } if ($constraint_values) { foreach ($constraint_values as $value) { $op = $value->getOperator(); switch ($op) { case PhabricatorQueryConstraint::OPERATOR_NULL: $constraint_parts[] = qsprintf( $conn, '%T.indexValue IS NULL', $alias); break; case PhabricatorQueryConstraint::OPERATOR_ANY: $constraint_parts[] = qsprintf( $conn, '%T.indexValue IS NOT NULL', $alias); break; default: throw new Exception( pht( 'No support for applying operator "%s" against '. 'index of type "%s".', $op, $type)); } } } if ($constraint_parts) { $where[] = qsprintf($conn, '%LO', $constraint_parts); } break; } } return $where; } /* -( 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)); } /* -( Ferret )------------------------------------------------------------- */ public function supportsFerretEngine() { $object = $this->newResultObject(); return ($object instanceof PhabricatorFerretInterface); } public function withFerretQuery( PhabricatorFerretEngine $engine, PhabricatorSavedQuery $query) { if (!$this->supportsFerretEngine()) { throw new Exception( pht( 'Query ("%s") does not support the Ferret fulltext engine.', get_class($this))); } $this->ferretEngine = $engine; $this->ferretQuery = $query; return $this; } public function getFerretTokens() { if (!$this->supportsFerretEngine()) { throw new Exception( pht( 'Query ("%s") does not support the Ferret fulltext engine.', get_class($this))); } return $this->ferretTokens; } public function withFerretConstraint( PhabricatorFerretEngine $engine, array $fulltext_tokens) { if (!$this->supportsFerretEngine()) { throw new Exception( pht( 'Query ("%s") does not support the Ferret fulltext engine.', get_class($this))); } if ($this->ferretEngine) { throw new Exception( pht( 'Query may not have multiple fulltext constraints.')); } if (!$fulltext_tokens) { return $this; } $this->ferretEngine = $engine; $this->ferretTokens = $fulltext_tokens; $op_absent = PhutilSearchQueryCompiler::OPERATOR_ABSENT; $default_function = $engine->getDefaultFunctionKey(); $table_map = array(); $idx = 1; foreach ($this->ferretTokens as $fulltext_token) { $raw_token = $fulltext_token->getToken(); $function = $raw_token->getFunction(); if ($function === null) { $function = $default_function; } $function_def = $engine->getFunctionForName($function); // NOTE: The query compiler guarantees that a query can not make a // field both "present" and "absent", so it's safe to just use the // first operator we encounter to determine whether the table is // optional or not. $operator = $raw_token->getOperator(); $is_optional = ($operator === $op_absent); if (!isset($table_map[$function])) { $alias = 'ftfield_'.$idx++; $table_map[$function] = array( 'alias' => $alias, 'function' => $function_def, 'optional' => $is_optional, ); } } // Join the title field separately so we can rank results. $table_map['rank'] = array( 'alias' => 'ft_rank', 'function' => $engine->getFunctionForName('title'), // See T13345. Not every document has a title, so we want to LEFT JOIN // this table to avoid excluding documents with no title that match // the query in other fields. 'optional' => true, ); $this->ferretTables = $table_map; return $this; } protected function buildFerretSelectClause(AphrontDatabaseConnection $conn) { $select = array(); if (!$this->supportsFerretEngine()) { return $select; } if (!$this->hasFerretOrder()) { // We only need to SELECT the virtual rank/relevance columns if we're // actually sorting the results by rank. return $select; } if (!$this->ferretEngine) { $select[] = qsprintf($conn, '0 AS %T', self::FULLTEXT_RANK); $select[] = qsprintf($conn, '0 AS %T', self::FULLTEXT_CREATED); $select[] = qsprintf($conn, '0 AS %T', self::FULLTEXT_MODIFIED); return $select; } $engine = $this->ferretEngine; $stemmer = $engine->newStemmer(); $op_sub = PhutilSearchQueryCompiler::OPERATOR_SUBSTRING; $op_not = PhutilSearchQueryCompiler::OPERATOR_NOT; $table_alias = 'ft_rank'; $parts = array(); foreach ($this->ferretTokens as $fulltext_token) { $raw_token = $fulltext_token->getToken(); $value = $raw_token->getValue(); if ($raw_token->getOperator() == $op_not) { // Ignore "not" terms when ranking, since they aren't useful. continue; } if ($raw_token->getOperator() == $op_sub) { $is_substring = true; } else { $is_substring = false; } if ($is_substring) { $parts[] = qsprintf( $conn, 'IF(%T.rawCorpus LIKE %~, 2, 0)', $table_alias, $value); continue; } if ($raw_token->isQuoted()) { $is_quoted = true; $is_stemmed = false; } else { $is_quoted = false; $is_stemmed = true; } $term_constraints = array(); $term_value = $engine->newTermsCorpus($value); $parts[] = qsprintf( $conn, 'IF(%T.termCorpus LIKE %~, 2, 0)', $table_alias, $term_value); if ($is_stemmed) { $stem_value = $stemmer->stemToken($value); $stem_value = $engine->newTermsCorpus($stem_value); $parts[] = qsprintf( $conn, 'IF(%T.normalCorpus LIKE %~, 1, 0)', $table_alias, $stem_value); } } $parts[] = qsprintf($conn, '%d', 0); $sum = array_shift($parts); foreach ($parts as $part) { $sum = qsprintf( $conn, '%Q + %Q', $sum, $part); } $select[] = qsprintf( $conn, '%Q AS %T', $sum, self::FULLTEXT_RANK); // See D20297. We select these as real columns in the result set so that // constructions like this will work: // // ((SELECT ...) UNION (SELECT ...)) ORDER BY ... // // If the columns aren't part of the result set, the final "ORDER BY" can // not act on them. $select[] = qsprintf( $conn, 'ft_doc.epochCreated AS %T', self::FULLTEXT_CREATED); $select[] = qsprintf( $conn, 'ft_doc.epochModified AS %T', self::FULLTEXT_MODIFIED); return $select; } protected function buildFerretJoinClause(AphrontDatabaseConnection $conn) { if (!$this->ferretEngine) { return array(); } $op_sub = PhutilSearchQueryCompiler::OPERATOR_SUBSTRING; $op_not = PhutilSearchQueryCompiler::OPERATOR_NOT; $op_absent = PhutilSearchQueryCompiler::OPERATOR_ABSENT; $op_present = PhutilSearchQueryCompiler::OPERATOR_PRESENT; $engine = $this->ferretEngine; $stemmer = $engine->newStemmer(); $ngram_table = $engine->getNgramsTableName(); $ngram_engine = $this->getNgramEngine(); $flat = array(); foreach ($this->ferretTokens as $fulltext_token) { $raw_token = $fulltext_token->getToken(); $operator = $raw_token->getOperator(); // If this is a negated term like "-pomegranate", don't join the ngram // table since we aren't looking for documents with this term. (We could // LEFT JOIN the table and require a NULL row, but this is probably more // trouble than it's worth.) if ($operator === $op_not) { continue; } // Neither the "present" or "absent" operators benefit from joining // the ngram table. if ($operator === $op_absent || $operator === $op_present) { continue; } $value = $raw_token->getValue(); $length = count(phutil_utf8v($value)); if ($raw_token->getOperator() == $op_sub) { $is_substring = true; } else { $is_substring = false; } // If the user specified a substring query for a substring which is // shorter than the ngram length, we can't use the ngram index, so // don't do a join. We'll fall back to just doing LIKE on the full // corpus. if ($is_substring) { if ($length < 3) { continue; } } if ($raw_token->isQuoted()) { $is_stemmed = false; } else { $is_stemmed = true; } if ($is_substring) { $ngrams = $ngram_engine->getSubstringNgramsFromString($value); } else { $terms_value = $engine->newTermsCorpus($value); $ngrams = $ngram_engine->getTermNgramsFromString($terms_value); // If this is a stemmed term, only look for ngrams present in both the // unstemmed and stemmed variations. if ($is_stemmed) { // Trim the boundary space characters so the stemmer recognizes this // is (or, at least, may be) a normal word and activates. $terms_value = trim($terms_value, ' '); $stem_value = $stemmer->stemToken($terms_value); $stem_ngrams = $ngram_engine->getTermNgramsFromString($stem_value); $ngrams = array_intersect($ngrams, $stem_ngrams); } } foreach ($ngrams as $ngram) { $flat[] = array( 'table' => $ngram_table, 'ngram' => $ngram, ); } } // Remove common ngrams, like "the", which occur too frequently in // documents to be useful in constraining the query. The best ngrams // are obscure sequences which occur in very few documents. if ($flat) { $common_ngrams = queryfx_all( $conn, 'SELECT ngram FROM %T WHERE ngram IN (%Ls)', $engine->getCommonNgramsTableName(), ipull($flat, 'ngram')); $common_ngrams = ipull($common_ngrams, 'ngram', 'ngram'); foreach ($flat as $key => $spec) { $ngram = $spec['ngram']; if (isset($common_ngrams[$ngram])) { unset($flat[$key]); continue; } // NOTE: MySQL discards trailing whitespace in CHAR(X) columns. $trim_ngram = rtrim($ngram, ' '); if (isset($common_ngrams[$trim_ngram])) { unset($flat[$key]); continue; } } } // 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) { $phid_column = qsprintf($conn, '%T.%T', $alias, 'phid'); } else { $phid_column = qsprintf($conn, '%T', 'phid'); } $document_table = $engine->getDocumentTableName(); $field_table = $engine->getFieldTableName(); $joins = array(); $joins[] = qsprintf( $conn, 'JOIN %T ft_doc ON ft_doc.objectPHID = %Q', $document_table, $phid_column); $idx = 1; foreach ($flat as $spec) { $table = $spec['table']; $ngram = $spec['ngram']; $alias = 'ftngram_'.$idx++; $joins[] = qsprintf( $conn, 'JOIN %T %T ON %T.documentID = ft_doc.id AND %T.ngram = %s', $table, $alias, $alias, $alias, $ngram); } $object = $this->newResultObject(); if (!$object) { throw new Exception( pht( 'Query class ("%s") must define "newResultObject()" to use '. 'Ferret constraints.', get_class($this))); } // See T13511. If we have a fulltext query which uses valid field // functions, but at least one of the functions applies to a field which // the object can never have, the query can never match anything. Detect // this and return an empty result set. // (Even if the query is "field is absent" or "field does not contain // such-and-such", the interpretation is that these constraints are // not meaningful when applied to an object which can never have the // field.) $functions = ipull($this->ferretTables, 'function'); $functions = mpull($functions, null, 'getFerretFunctionName'); foreach ($functions as $function) { if (!$function->supportsObject($object)) { throw new PhabricatorEmptyQueryException( pht( 'This query uses a fulltext function which this document '. 'type does not support.')); } } foreach ($this->ferretTables as $table) { $alias = $table['alias']; if (empty($table['optional'])) { $join_type = qsprintf($conn, 'JOIN'); } else { $join_type = qsprintf($conn, 'LEFT JOIN'); } $joins[] = qsprintf( $conn, '%Q %T %T ON ft_doc.id = %T.documentID AND %T.fieldKey = %s', $join_type, $field_table, $alias, $alias, $alias, $table['function']->getFerretFieldKey()); } return $joins; } protected function buildFerretWhereClause(AphrontDatabaseConnection $conn) { if (!$this->ferretEngine) { return array(); } $engine = $this->ferretEngine; $stemmer = $engine->newStemmer(); $table_map = $this->ferretTables; $op_sub = PhutilSearchQueryCompiler::OPERATOR_SUBSTRING; $op_not = PhutilSearchQueryCompiler::OPERATOR_NOT; $op_exact = PhutilSearchQueryCompiler::OPERATOR_EXACT; $op_absent = PhutilSearchQueryCompiler::OPERATOR_ABSENT; $op_present = PhutilSearchQueryCompiler::OPERATOR_PRESENT; $where = array(); $default_function = $engine->getDefaultFunctionKey(); foreach ($this->ferretTokens as $fulltext_token) { $raw_token = $fulltext_token->getToken(); $value = $raw_token->getValue(); $function = $raw_token->getFunction(); if ($function === null) { $function = $default_function; } $operator = $raw_token->getOperator(); $table_alias = $table_map[$function]['alias']; // If this is a "field is present" operator, we've already implicitly // guaranteed this by JOINing the table. We don't need to do any // more work. $is_present = ($operator === $op_present); if ($is_present) { continue; } // If this is a "field is absent" operator, we just want documents // which failed to match to a row when we LEFT JOINed the table. This // means there's no index for the field. $is_absent = ($operator === $op_absent); if ($is_absent) { $where[] = qsprintf( $conn, '(%T.rawCorpus IS NULL)', $table_alias); continue; } $is_not = ($operator === $op_not); if ($operator == $op_sub) { $is_substring = true; } else { $is_substring = false; } // If we're doing exact search, just test the raw corpus. $is_exact = ($operator === $op_exact); if ($is_exact) { if ($is_not) { $where[] = qsprintf( $conn, '(%T.rawCorpus != %s)', $table_alias, $value); } else { $where[] = qsprintf( $conn, '(%T.rawCorpus = %s)', $table_alias, $value); } continue; } // If we're doing substring search, we just match against the raw corpus // and we're done. if ($is_substring) { if ($is_not) { $where[] = qsprintf( $conn, '(%T.rawCorpus NOT LIKE %~)', $table_alias, $value); } else { $where[] = qsprintf( $conn, '(%T.rawCorpus LIKE %~)', $table_alias, $value); } continue; } // Otherwise, we need to match against the term corpus and the normal // corpus, so that searching for "raw" does not find "strawberry". if ($raw_token->isQuoted()) { $is_quoted = true; $is_stemmed = false; } else { $is_quoted = false; $is_stemmed = true; } // Never stem negated queries, since this can exclude results users // did not mean to exclude and generally confuse things. if ($is_not) { $is_stemmed = false; } $term_constraints = array(); $term_value = $engine->newTermsCorpus($value); if ($is_not) { $term_constraints[] = qsprintf( $conn, '(%T.termCorpus NOT LIKE %~)', $table_alias, $term_value); } else { $term_constraints[] = qsprintf( $conn, '(%T.termCorpus LIKE %~)', $table_alias, $term_value); } if ($is_stemmed) { $stem_value = $stemmer->stemToken($value); $stem_value = $engine->newTermsCorpus($stem_value); $term_constraints[] = qsprintf( $conn, '(%T.normalCorpus LIKE %~)', $table_alias, $stem_value); } if ($is_not) { $where[] = qsprintf( $conn, '%LA', $term_constraints); } else if ($is_quoted) { $where[] = qsprintf( $conn, '(%T.rawCorpus LIKE %~ AND %LO)', $table_alias, $value, $term_constraints); } else { $where[] = qsprintf( $conn, '%LO', $term_constraints); } } if ($this->ferretQuery) { $query = $this->ferretQuery; $author_phids = $query->getParameter('authorPHIDs'); if ($author_phids) { $where[] = qsprintf( $conn, 'ft_doc.authorPHID IN (%Ls)', $author_phids); } $with_unowned = $query->getParameter('withUnowned'); $with_any = $query->getParameter('withAnyOwner'); if ($with_any && $with_unowned) { throw new PhabricatorEmptyQueryException( pht( 'This query matches only unowned documents owned by anyone, '. 'which is impossible.')); } $owner_phids = $query->getParameter('ownerPHIDs'); if ($owner_phids && !$with_any) { if ($with_unowned) { $where[] = qsprintf( $conn, 'ft_doc.ownerPHID IN (%Ls) OR ft_doc.ownerPHID IS NULL', $owner_phids); } else { $where[] = qsprintf( $conn, 'ft_doc.ownerPHID IN (%Ls)', $owner_phids); } } else if ($with_unowned) { $where[] = qsprintf( $conn, 'ft_doc.ownerPHID IS NULL'); } if ($with_any) { $where[] = qsprintf( $conn, 'ft_doc.ownerPHID IS NOT NULL'); } $rel_open = PhabricatorSearchRelationship::RELATIONSHIP_OPEN; $statuses = $query->getParameter('statuses'); $is_closed = null; if ($statuses) { $statuses = array_fuse($statuses); if (count($statuses) == 1) { if (isset($statuses[$rel_open])) { $is_closed = 0; } else { $is_closed = 1; } } } if ($is_closed !== null) { $where[] = qsprintf( $conn, 'ft_doc.isClosed = %d', $is_closed); } } return $where; } protected function shouldGroupFerretResultRows() { return (bool)$this->ferretTokens; } /* -( 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) { $ngram_engine = $this->getNgramEngine(); $flat = array(); foreach ($this->ngrams as $spec) { $length = $spec['length']; if ($length < 3) { continue; } $index = $spec['index']; $value = $spec['value']; $ngrams = $ngram_engine->getSubstringNgramsFromString($value); foreach ($ngrams as $ngram) { $flat[] = array( 'table' => $index->getTableName(), 'ngram' => $ngram, ); } } if (!$flat) { return array(); } // 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']; $alias = 'ngm'.$idx++; $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(); $ngram_engine = $this->getNgramEngine(); 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 = $ngram_engine->tokenizeNgramString($value); foreach ($tokens as $token) { $where[] = qsprintf( $conn, '%Q LIKE %~', $column, $token); } } return $where; } protected function shouldGroupNgramResultRows() { return (bool)$this->ngrams; } private function getNgramEngine() { if (!$this->ngramEngine) { $this->ngramEngine = new PhabricatorSearchNgramEngine(); } return $this->ngramEngine; } /* -( 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); } /** * @return this * @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 = qsprintf($conn, '%LQ', $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($conn); $joins = array(); foreach ($this->edgeLogicConstraints as $type => $constraints) { $op_null = PhabricatorQueryConstraint::OPERATOR_NULL; $has_null = isset($constraints[$op_null]); // If we're going to process an only() operator, build a list of the // acceptable set of PHIDs first. We'll only match results which have // no edges to any other PHIDs. $all_phids = array(); if (isset($constraints[PhabricatorQueryConstraint::OPERATOR_ONLY])) { foreach ($constraints as $operator => $list) { switch ($operator) { case PhabricatorQueryConstraint::OPERATOR_ANCESTOR: case PhabricatorQueryConstraint::OPERATOR_AND: case PhabricatorQueryConstraint::OPERATOR_OR: foreach ($list as $constraint) { $value = (array)$constraint->getValue(); foreach ($value as $v) { $all_phids[$v] = $v; } } break; } } } 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 = qsprintf($conn, 'LEFT'); } else { $join_type = qsprintf($conn, ''); } $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; case PhabricatorQueryConstraint::OPERATOR_ONLY: $joins[] = qsprintf( $conn, 'LEFT JOIN %T %T ON %Q = %T.src AND %T.type = %d AND %T.dst NOT IN (%Ls)', $edge_table, $alias, $phid_column, $alias, $alias, $type, $alias, $all_phids); 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: case PhabricatorQueryConstraint::OPERATOR_ONLY: $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) { $where[] = qsprintf($conn, '(%LA OR %LA)', $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: if (count($list) > 1) { return true; } break; case PhabricatorQueryConstraint::OPERATOR_ANCESTOR: // NOTE: We must always group query results rows when using an // "ANCESTOR" operator because a single task may be related to // two different descendants of a particular ancestor. For // discussion, see T12753. return true; case PhabricatorQueryConstraint::OPERATOR_NULL: case PhabricatorQueryConstraint::OPERATOR_ONLY: 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.')); } } } $op_and = PhabricatorQueryConstraint::OPERATOR_AND; $op_or = PhabricatorQueryConstraint::OPERATOR_OR; $op_ancestor = PhabricatorQueryConstraint::OPERATOR_ANCESTOR; foreach ($this->edgeLogicConstraints as $type => $constraints) { foreach ($constraints as $operator => $list) { switch ($operator) { case PhabricatorQueryConstraint::OPERATOR_ONLY: if (count($list) > 1) { throw new PhabricatorEmptyQueryException( pht( 'This query specifies only() more than once.')); } $have_and = idx($constraints, $op_and); $have_or = idx($constraints, $op_or); $have_ancestor = idx($constraints, $op_ancestor); if (!$have_and && !$have_or && !$have_ancestor) { throw new PhabricatorEmptyQueryException( pht( 'This query specifies only(), but no other constraints '. 'which it can apply to.')); } break; } } } $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; } } // See T13240. If this query raises policy exceptions, don't filter objects // in the MySQL layer. We want them to reach the application layer so we // can reject them and raise an exception. if ($this->shouldRaisePolicyExceptions()) { 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 = qsprintf($conn, '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); } } private function hasFerretOrder() { $vector = $this->getOrderVector(); if ($vector->containsKey('rank')) { return true; } if ($vector->containsKey('fulltext-created')) { return true; } if ($vector->containsKey('fulltext-modified')) { return true; } return false; } } diff --git a/src/infrastructure/storage/management/workflow/PhabricatorStorageManagementWorkflow.php b/src/infrastructure/storage/management/workflow/PhabricatorStorageManagementWorkflow.php index cc2073d8c9..490036e36d 100644 --- a/src/infrastructure/storage/management/workflow/PhabricatorStorageManagementWorkflow.php +++ b/src/infrastructure/storage/management/workflow/PhabricatorStorageManagementWorkflow.php @@ -1,1287 +1,1287 @@ apis = $apis; return $this; } final public function getAnyAPI() { return head($this->getAPIs()); } final public function getMasterAPIs() { $apis = $this->getAPIs(); $results = array(); foreach ($apis as $api) { if ($api->getRef()->getIsMaster()) { $results[] = $api; } } if (!$results) { throw new PhutilArgumentUsageException( pht( 'This command only operates on database masters, but the selected '. 'database hosts do not include any masters.')); } return $results; } final public function getSingleAPI() { $apis = $this->getAPIs(); if (count($apis) == 1) { return head($apis); } throw new PhutilArgumentUsageException( pht( 'Phabricator is configured in cluster mode, with multiple database '. 'hosts. Use "--host" to specify which host you want to operate on.')); } final public function getAPIs() { return $this->apis; } final protected function isDryRun() { return $this->dryRun; } final protected function setDryRun($dry_run) { $this->dryRun = $dry_run; return $this; } final protected function isForce() { return $this->force; } final protected function setForce($force) { $this->force = $force; return $this; } public function getPatches() { return $this->patches; } public function setPatches(array $patches) { assert_instances_of($patches, 'PhabricatorStoragePatch'); $this->patches = $patches; return $this; } protected function isReadOnlyWorkflow() { return false; } public function execute(PhutilArgumentParser $args) { $this->setDryRun($args->getArg('dryrun')); $this->setForce($args->getArg('force')); if (!$this->isReadOnlyWorkflow()) { if (PhabricatorEnv::isReadOnly()) { if ($this->isForce()) { PhabricatorEnv::setReadOnly(false, null); } else { throw new PhutilArgumentUsageException( pht( 'Phabricator is currently in read-only mode. Use --force to '. 'override this mode.')); } } } return $this->didExecute($args); } public function didExecute(PhutilArgumentParser $args) {} private function loadSchemata(PhabricatorStorageManagementAPI $api) { $query = id(new PhabricatorConfigSchemaQuery()); $ref = $api->getRef(); $ref_key = $ref->getRefKey(); $query->setAPIs(array($api)); $query->setRefs(array($ref)); $actual = $query->loadActualSchemata(); $expect = $query->loadExpectedSchemata(); $comp = $query->buildComparisonSchemata($expect, $actual); return array( $comp[$ref_key], $expect[$ref_key], $actual[$ref_key], ); } final protected function adjustSchemata( PhabricatorStorageManagementAPI $api, $unsafe) { $lock = $this->lock($api); try { $err = $this->doAdjustSchemata($api, $unsafe); // Analyze tables if we're not doing a dry run and adjustments are either // all clear or have minor errors like surplus tables. if (!$this->dryRun) { $should_analyze = (($err == 0) || ($err == 2)); if ($should_analyze) { $this->analyzeTables($api); } } } catch (Exception $ex) { $lock->unlock(); throw $ex; } $lock->unlock(); return $err; } - final private function doAdjustSchemata( + private function doAdjustSchemata( PhabricatorStorageManagementAPI $api, $unsafe) { $console = PhutilConsole::getConsole(); $console->writeOut( "%s\n", pht( 'Verifying database schemata on "%s"...', $api->getRef()->getRefKey())); list($adjustments, $errors) = $this->findAdjustments($api); if (!$adjustments) { $console->writeOut( "%s\n", pht('Found no adjustments for schemata.')); return $this->printErrors($errors, 0); } if (!$this->force && !$api->isCharacterSetAvailable('utf8mb4')) { $message = pht( "You have an old version of MySQL (older than 5.5) which does not ". "support the utf8mb4 character set. We strongly recommend upgrading ". "to 5.5 or newer.\n\n". "If you apply adjustments now and later update MySQL to 5.5 or newer, ". "you'll need to apply adjustments again (and they will take a long ". "time).\n\n". "You can exit this workflow, update MySQL now, and then run this ". "workflow again. This is recommended, but may cause a lot of downtime ". "right now.\n\n". "You can exit this workflow, continue using Phabricator without ". "applying adjustments, update MySQL at a later date, and then run ". "this workflow again. This is also a good approach, and will let you ". "delay downtime until later.\n\n". "You can proceed with this workflow, and then optionally update ". "MySQL at a later date. After you do, you'll need to apply ". "adjustments again.\n\n". "For more information, see \"Managing Storage Adjustments\" in ". "the documentation."); $console->writeOut( "\n** %s **\n\n%s\n", pht('OLD MySQL VERSION'), phutil_console_wrap($message)); $prompt = pht('Continue with old MySQL version?'); if (!phutil_console_confirm($prompt, $default_no = true)) { return; } } $table = id(new PhutilConsoleTable()) ->addColumn('database', array('title' => pht('Database'))) ->addColumn('table', array('title' => pht('Table'))) ->addColumn('name', array('title' => pht('Name'))) ->addColumn('info', array('title' => pht('Issues'))); foreach ($adjustments as $adjust) { $info = array(); foreach ($adjust['issues'] as $issue) { $info[] = PhabricatorConfigStorageSchema::getIssueName($issue); } $table->addRow(array( 'database' => $adjust['database'], 'table' => idx($adjust, 'table'), 'name' => idx($adjust, 'name'), 'info' => implode(', ', $info), )); } $console->writeOut("\n\n"); $table->draw(); if ($this->dryRun) { $console->writeOut( "%s\n", pht('DRYRUN: Would apply adjustments.')); return 0; } else if ($this->didInitialize) { // If we just initialized the database, continue without prompting. This // is nicer for first-time setup and there's no reasonable reason any // user would ever answer "no" to the prompt against an empty schema. } else if (!$this->force) { $console->writeOut( "\n%s\n", pht( "Found %s adjustment(s) to apply, detailed above.\n\n". "You can review adjustments in more detail from the web interface, ". "in Config > Database Status. To better understand the adjustment ". "workflow, see \"Managing Storage Adjustments\" in the ". "documentation.\n\n". "MySQL needs to copy table data to make some adjustments, so these ". "migrations may take some time.", phutil_count($adjustments))); $prompt = pht('Apply these schema adjustments?'); if (!phutil_console_confirm($prompt, $default_no = true)) { return 1; } } $console->writeOut( "%s\n", pht('Applying schema adjustments...')); $conn = $api->getConn(null); if ($unsafe) { queryfx($conn, 'SET SESSION sql_mode = %s', ''); } else { queryfx($conn, 'SET SESSION sql_mode = %s', 'STRICT_ALL_TABLES'); } $failed = array(); // We make changes in several phases. $phases = array( // Drop surplus autoincrements. This allows us to drop primary keys on // autoincrement columns. 'drop_auto', // Drop all keys we're going to adjust. This prevents them from // interfering with column changes. 'drop_keys', // Apply all database, table, and column changes. 'main', // Restore adjusted keys. 'add_keys', // Add missing autoincrements. 'add_auto', ); $bar = id(new PhutilConsoleProgressBar()) ->setTotal(count($adjustments) * count($phases)); foreach ($phases as $phase) { foreach ($adjustments as $adjust) { try { switch ($adjust['kind']) { case 'database': if ($phase == 'main') { queryfx( $conn, 'ALTER DATABASE %T CHARACTER SET = %s COLLATE = %s', $adjust['database'], $adjust['charset'], $adjust['collation']); } break; case 'table': if ($phase == 'main') { queryfx( $conn, 'ALTER TABLE %T.%T COLLATE = %s, ENGINE = %s', $adjust['database'], $adjust['table'], $adjust['collation'], $adjust['engine']); } break; case 'column': $apply = false; $auto = false; $new_auto = idx($adjust, 'auto'); if ($phase == 'drop_auto') { if ($new_auto === false) { $apply = true; $auto = false; } } else if ($phase == 'main') { $apply = true; if ($new_auto === false) { $auto = false; } else { $auto = $adjust['is_auto']; } } else if ($phase == 'add_auto') { if ($new_auto === true) { $apply = true; $auto = true; } } if ($apply) { $parts = array(); if ($auto) { $parts[] = qsprintf( $conn, 'AUTO_INCREMENT'); } if ($adjust['charset']) { switch ($adjust['charset']) { case 'binary': $charset_value = qsprintf($conn, 'binary'); break; case 'utf8': $charset_value = qsprintf($conn, 'utf8'); break; case 'utf8mb4': $charset_value = qsprintf($conn, 'utf8mb4'); break; default: throw new Exception( pht( 'Unsupported character set "%s".', $adjust['charset'])); } switch ($adjust['collation']) { case 'binary': $collation_value = qsprintf($conn, 'binary'); break; case 'utf8_general_ci': $collation_value = qsprintf($conn, 'utf8_general_ci'); break; case 'utf8mb4_bin': $collation_value = qsprintf($conn, 'utf8mb4_bin'); break; case 'utf8mb4_unicode_ci': $collation_value = qsprintf($conn, 'utf8mb4_unicode_ci'); break; default: throw new Exception( pht( 'Unsupported collation set "%s".', $adjust['collation'])); } $parts[] = qsprintf( $conn, 'CHARACTER SET %Q COLLATE %Q', $charset_value, $collation_value); } if ($parts) { $parts = qsprintf($conn, '%LJ', $parts); } else { $parts = qsprintf($conn, ''); } if ($adjust['nullable']) { $nullable = qsprintf($conn, 'NULL'); } else { $nullable = qsprintf($conn, 'NOT NULL'); } // TODO: We're using "%Z" here for the column type, which is // technically unsafe. It would be nice to be able to use "%Q" // instead, but this requires a fair amount of legwork to // enumerate all column types. queryfx( $conn, 'ALTER TABLE %T.%T MODIFY %T %Z %Q %Q', $adjust['database'], $adjust['table'], $adjust['name'], $adjust['type'], $parts, $nullable); } break; case 'key': if (($phase == 'drop_keys') && $adjust['exists']) { if ($adjust['name'] == 'PRIMARY') { $key_name = qsprintf($conn, 'PRIMARY KEY'); } else { $key_name = qsprintf($conn, 'KEY %T', $adjust['name']); } queryfx( $conn, 'ALTER TABLE %T.%T DROP %Q', $adjust['database'], $adjust['table'], $key_name); } if (($phase == 'add_keys') && $adjust['keep']) { // Different keys need different creation syntax. Notable // special cases are primary keys and fulltext keys. if ($adjust['name'] == 'PRIMARY') { $key_name = qsprintf($conn, 'PRIMARY KEY'); } else if ($adjust['indexType'] == 'FULLTEXT') { $key_name = qsprintf($conn, 'FULLTEXT %T', $adjust['name']); } else { if ($adjust['unique']) { $key_name = qsprintf( $conn, 'UNIQUE KEY %T', $adjust['name']); } else { $key_name = qsprintf( $conn, '/* NONUNIQUE */ KEY %T', $adjust['name']); } } queryfx( $conn, 'ALTER TABLE %T.%T ADD %Q (%LK)', $adjust['database'], $adjust['table'], $key_name, $adjust['columns']); } break; default: throw new Exception( pht('Unknown schema adjustment kind "%s"!', $adjust['kind'])); } } catch (AphrontQueryException $ex) { $failed[] = array($adjust, $ex); } $bar->update(1); } } $bar->done(); if (!$failed) { $console->writeOut( "%s\n", pht('Completed applying all schema adjustments.')); $err = 0; } else { $table = id(new PhutilConsoleTable()) ->addColumn('target', array('title' => pht('Target'))) ->addColumn('error', array('title' => pht('Error'))); foreach ($failed as $failure) { list($adjust, $ex) = $failure; $pieces = array_select_keys( $adjust, array('database', 'table', 'name')); $pieces = array_filter($pieces); $target = implode('.', $pieces); $table->addRow( array( 'target' => $target, 'error' => $ex->getMessage(), )); } $console->writeOut("\n"); $table->draw(); $console->writeOut( "\n%s\n", pht('Failed to make some schema adjustments, detailed above.')); $console->writeOut( "%s\n", pht( 'For help troubleshooting adjustments, see "Managing Storage '. 'Adjustments" in the documentation.')); $err = 1; } return $this->printErrors($errors, $err); } private function findAdjustments( PhabricatorStorageManagementAPI $api) { list($comp, $expect, $actual) = $this->loadSchemata($api); $issue_charset = PhabricatorConfigStorageSchema::ISSUE_CHARSET; $issue_collation = PhabricatorConfigStorageSchema::ISSUE_COLLATION; $issue_columntype = PhabricatorConfigStorageSchema::ISSUE_COLUMNTYPE; $issue_surpluskey = PhabricatorConfigStorageSchema::ISSUE_SURPLUSKEY; $issue_missingkey = PhabricatorConfigStorageSchema::ISSUE_MISSINGKEY; $issue_columns = PhabricatorConfigStorageSchema::ISSUE_KEYCOLUMNS; $issue_unique = PhabricatorConfigStorageSchema::ISSUE_UNIQUE; $issue_longkey = PhabricatorConfigStorageSchema::ISSUE_LONGKEY; $issue_auto = PhabricatorConfigStorageSchema::ISSUE_AUTOINCREMENT; $issue_engine = PhabricatorConfigStorageSchema::ISSUE_ENGINE; $adjustments = array(); $errors = array(); foreach ($comp->getDatabases() as $database_name => $database) { foreach ($this->findErrors($database) as $issue) { $errors[] = array( 'database' => $database_name, 'issue' => $issue, ); } $expect_database = $expect->getDatabase($database_name); $actual_database = $actual->getDatabase($database_name); if (!$expect_database || !$actual_database) { // If there's a real issue here, skip this stuff. continue; } if ($actual_database->getAccessDenied()) { // If we can't access the database, we can't access the tables either. continue; } $issues = array(); if ($database->hasIssue($issue_charset)) { $issues[] = $issue_charset; } if ($database->hasIssue($issue_collation)) { $issues[] = $issue_collation; } if ($issues) { $adjustments[] = array( 'kind' => 'database', 'database' => $database_name, 'issues' => $issues, 'charset' => $expect_database->getCharacterSet(), 'collation' => $expect_database->getCollation(), ); } foreach ($database->getTables() as $table_name => $table) { foreach ($this->findErrors($table) as $issue) { $errors[] = array( 'database' => $database_name, 'table' => $table_name, 'issue' => $issue, ); } $expect_table = $expect_database->getTable($table_name); $actual_table = $actual_database->getTable($table_name); if (!$expect_table || !$actual_table) { continue; } $issues = array(); if ($table->hasIssue($issue_collation)) { $issues[] = $issue_collation; } if ($table->hasIssue($issue_engine)) { $issues[] = $issue_engine; } if ($issues) { $adjustments[] = array( 'kind' => 'table', 'database' => $database_name, 'table' => $table_name, 'issues' => $issues, 'collation' => $expect_table->getCollation(), 'engine' => $expect_table->getEngine(), ); } foreach ($table->getColumns() as $column_name => $column) { foreach ($this->findErrors($column) as $issue) { $errors[] = array( 'database' => $database_name, 'table' => $table_name, 'name' => $column_name, 'issue' => $issue, ); } $expect_column = $expect_table->getColumn($column_name); $actual_column = $actual_table->getColumn($column_name); if (!$expect_column || !$actual_column) { continue; } $issues = array(); if ($column->hasIssue($issue_collation)) { $issues[] = $issue_collation; } if ($column->hasIssue($issue_charset)) { $issues[] = $issue_charset; } if ($column->hasIssue($issue_columntype)) { $issues[] = $issue_columntype; } if ($column->hasIssue($issue_auto)) { $issues[] = $issue_auto; } if ($issues) { if ($expect_column->getCharacterSet() === null) { // For non-text columns, we won't be specifying a collation or // character set. $charset = null; $collation = null; } else { $charset = $expect_column->getCharacterSet(); $collation = $expect_column->getCollation(); } $adjustment = array( 'kind' => 'column', 'database' => $database_name, 'table' => $table_name, 'name' => $column_name, 'issues' => $issues, 'collation' => $collation, 'charset' => $charset, 'type' => $expect_column->getColumnType(), // NOTE: We don't adjust column nullability because it is // dangerous, so always use the current nullability. 'nullable' => $actual_column->getNullable(), // NOTE: This always stores the current value, because we have // to make these updates separately. 'is_auto' => $actual_column->getAutoIncrement(), ); if ($column->hasIssue($issue_auto)) { $adjustment['auto'] = $expect_column->getAutoIncrement(); } $adjustments[] = $adjustment; } } foreach ($table->getKeys() as $key_name => $key) { foreach ($this->findErrors($key) as $issue) { $errors[] = array( 'database' => $database_name, 'table' => $table_name, 'name' => $key_name, 'issue' => $issue, ); } $expect_key = $expect_table->getKey($key_name); $actual_key = $actual_table->getKey($key_name); $issues = array(); $keep_key = true; if ($key->hasIssue($issue_surpluskey)) { $issues[] = $issue_surpluskey; $keep_key = false; } if ($key->hasIssue($issue_missingkey)) { $issues[] = $issue_missingkey; } if ($key->hasIssue($issue_columns)) { $issues[] = $issue_columns; } if ($key->hasIssue($issue_unique)) { $issues[] = $issue_unique; } // NOTE: We can't really fix this, per se, but we may need to remove // the key to change the column type. In the best case, the new // column type won't be overlong and recreating the key really will // fix the issue. In the worst case, we get the right column type and // lose the key, which is still better than retaining the key having // the wrong column type. if ($key->hasIssue($issue_longkey)) { $issues[] = $issue_longkey; } if ($issues) { $adjustment = array( 'kind' => 'key', 'database' => $database_name, 'table' => $table_name, 'name' => $key_name, 'issues' => $issues, 'exists' => (bool)$actual_key, 'keep' => $keep_key, ); if ($keep_key) { $adjustment += array( 'columns' => $expect_key->getColumnNames(), 'unique' => $expect_key->getUnique(), 'indexType' => $expect_key->getIndexType(), ); } $adjustments[] = $adjustment; } } } } return array($adjustments, $errors); } private function findErrors(PhabricatorConfigStorageSchema $schema) { $result = array(); foreach ($schema->getLocalIssues() as $issue) { $status = PhabricatorConfigStorageSchema::getIssueStatus($issue); if ($status == PhabricatorConfigStorageSchema::STATUS_FAIL) { $result[] = $issue; } } return $result; } private function printErrors(array $errors, $default_return) { if (!$errors) { return $default_return; } $console = PhutilConsole::getConsole(); $table = id(new PhutilConsoleTable()) ->addColumn('target', array('title' => pht('Target'))) ->addColumn('error', array('title' => pht('Error'))); $any_surplus = false; $all_surplus = true; $any_access = false; $all_access = true; foreach ($errors as $error) { $pieces = array_select_keys( $error, array('database', 'table', 'name')); $pieces = array_filter($pieces); $target = implode('.', $pieces); $name = PhabricatorConfigStorageSchema::getIssueName($error['issue']); $issue = $error['issue']; if ($issue === PhabricatorConfigStorageSchema::ISSUE_SURPLUS) { $any_surplus = true; } else { $all_surplus = false; } if ($issue === PhabricatorConfigStorageSchema::ISSUE_ACCESSDENIED) { $any_access = true; } else { $all_access = false; } $table->addRow( array( 'target' => $target, 'error' => $name, )); } $console->writeOut("\n"); $table->draw(); $console->writeOut("\n"); $message = array(); if ($all_surplus) { $message[] = pht( 'You have surplus schemata (extra tables or columns which Phabricator '. 'does not expect). For information on resolving these '. 'issues, see the "Surplus Schemata" section in the "Managing Storage '. 'Adjustments" article in the documentation.'); } else if ($all_access) { $message[] = pht( 'The user you are connecting to MySQL with does not have the correct '. 'permissions, and can not access some databases or tables that it '. 'needs to be able to access. GRANT the user additional permissions.'); } else { $message[] = pht( 'The schemata have errors (detailed above) which the adjustment '. 'workflow can not fix.'); if ($any_access) { $message[] = pht( 'Some of these errors are caused by access control problems. '. 'The user you are connecting with does not have permission to see '. 'all of the database or tables that Phabricator uses. You need to '. 'GRANT the user more permission, or use a different user.'); } if ($any_surplus) { $message[] = pht( 'Some of these errors are caused by surplus schemata (extra '. 'tables or columns which Phabricator does not expect). These are '. 'not serious. For information on resolving these issues, see the '. '"Surplus Schemata" section in the "Managing Storage Adjustments" '. 'article in the documentation.'); } $message[] = pht( 'If you are not developing Phabricator itself, report this issue to '. 'the upstream.'); $message[] = pht( 'If you are developing Phabricator, these errors usually indicate '. 'that your schema specifications do not agree with the schemata your '. 'code actually builds.'); } $message = implode("\n\n", $message); if ($all_surplus) { $console->writeOut( "** %s **\n\n%s\n", pht('SURPLUS SCHEMATA'), phutil_console_wrap($message)); } else if ($all_access) { $console->writeOut( "** %s **\n\n%s\n", pht('ACCESS DENIED'), phutil_console_wrap($message)); } else { $console->writeOut( "** %s **\n\n%s\n", pht('SCHEMATA ERRORS'), phutil_console_wrap($message)); } return 2; } final protected function upgradeSchemata( array $apis, $apply_only = null, $no_quickstart = false, $init_only = false) { $locks = array(); foreach ($apis as $api) { $locks[] = $this->lock($api); } try { $this->doUpgradeSchemata($apis, $apply_only, $no_quickstart, $init_only); } catch (Exception $ex) { foreach ($locks as $lock) { $lock->unlock(); } throw $ex; } foreach ($locks as $lock) { $lock->unlock(); } } - final private function doUpgradeSchemata( + private function doUpgradeSchemata( array $apis, $apply_only, $no_quickstart, $init_only) { $patches = $this->patches; $is_dryrun = $this->dryRun; // We expect that patches should already be sorted properly. However, // phase behavior will be wrong if they aren't, so make sure. $patches = msortv($patches, 'newSortVector'); $api_map = array(); foreach ($apis as $api) { $api_map[$api->getRef()->getRefKey()] = $api; } foreach ($api_map as $ref_key => $api) { $applied = $api->getAppliedPatches(); $needs_init = ($applied === null); if (!$needs_init) { continue; } if ($is_dryrun) { echo tsprintf( "%s\n", pht( 'DRYRUN: Storage on host "%s" does not exist yet, so it '. 'would be created.', $ref_key)); continue; } if ($apply_only) { throw new PhutilArgumentUsageException( pht( 'Storage on host "%s" has not been initialized yet. You must '. 'initialize storage before selectively applying patches.', $ref_key)); } // If we're initializing storage for the first time on any host, track // it so that we can give the user a nicer experience during the // subsequent adjustment phase. $this->didInitialize = true; $legacy = $api->getLegacyPatches($patches); if ($legacy || $no_quickstart || $init_only) { // If we have legacy patches, we can't quickstart. $api->createDatabase('meta_data'); $api->createTable( 'meta_data', 'patch_status', array( 'patch VARCHAR(255) NOT NULL PRIMARY KEY COLLATE utf8_general_ci', 'applied INT UNSIGNED NOT NULL', )); foreach ($legacy as $patch) { $api->markPatchApplied($patch); } } else { echo tsprintf( "%s\n", pht( 'Loading quickstart template onto "%s"...', $ref_key)); $root = dirname(phutil_get_library_root('phabricator')); $sql = $root.'/resources/sql/quickstart.sql'; $api->applyPatchSQL($sql); } } if ($init_only) { echo pht('Storage initialized.')."\n"; return 0; } $applied_map = array(); $state_map = array(); foreach ($api_map as $ref_key => $api) { $applied = $api->getAppliedPatches(); // If we still have nothing applied, this is a dry run and we didn't // actually initialize storage. Here, just do nothing. if ($applied === null) { if ($is_dryrun) { continue; } else { throw new Exception( pht( 'Database initialization on host "%s" applied no patches!', $ref_key)); } } $applied = array_fuse($applied); $state_map[$ref_key] = $applied; if ($apply_only) { if (isset($applied[$apply_only])) { if (!$this->force && !$is_dryrun) { echo phutil_console_wrap( pht( 'Patch "%s" has already been applied on host "%s". Are you '. 'sure you want to apply it again? This may put your storage '. 'in a state that the upgrade scripts can not automatically '. 'manage.', $apply_only, $ref_key)); if (!phutil_console_confirm(pht('Apply patch again?'))) { echo pht('Cancelled.')."\n"; return 1; } } // Mark this patch as not yet applied on this host. unset($applied[$apply_only]); } } $applied_map[$ref_key] = $applied; } // If we're applying only a specific patch, select just that patch. if ($apply_only) { $patches = array_select_keys($patches, array($apply_only)); } // Apply each patch to each database. We apply patches patch-by-patch, // not database-by-database: for each patch we apply it to every database, // then move to the next patch. // We must do this because ".php" patches may depend on ".sql" patches // being up to date on all masters, and that will work fine if we put each // patch on every host before moving on. If we try to bring database hosts // up to date one at a time we can end up in a big mess. $duration_map = array(); // First, find any global patches which have been applied to ANY database. // We are just going to mark these as applied without actually running // them. Otherwise, adding new empty masters to an existing cluster will // try to apply them against invalid states. foreach ($patches as $key => $patch) { if ($patch->getIsGlobalPatch()) { foreach ($applied_map as $ref_key => $applied) { if (isset($applied[$key])) { $duration_map[$key] = 1; } } } } while (true) { $applied_something = false; foreach ($patches as $key => $patch) { // First, check if any databases need this patch. We can just skip it // if it has already been applied everywhere. $need_patch = array(); foreach ($applied_map as $ref_key => $applied) { if (isset($applied[$key])) { continue; } $need_patch[] = $ref_key; } if (!$need_patch) { unset($patches[$key]); continue; } // Check if we can apply this patch yet. Before we can apply a patch, // all of the dependencies for the patch must have been applied on all // databases. Requiring that all databases stay in sync prevents one // database from racing ahead if it happens to get a patch that nothing // else has yet. $missing_patch = null; foreach ($patch->getAfter() as $after) { foreach ($applied_map as $ref_key => $applied) { if (isset($applied[$after])) { // This database already has the patch. We can apply it to // other databases but don't need to apply it here. continue; } $missing_patch = $after; break 2; } } if ($missing_patch) { if ($apply_only) { echo tsprintf( "%s\n", pht( 'Unable to apply patch "%s" because it depends on patch '. '"%s", which has not been applied on some hosts: %s.', $apply_only, $missing_patch, implode(', ', $need_patch))); return 1; } else { // Some databases are missing the dependencies, so keep trying // other patches instead. If everything goes right, we'll apply the // dependencies and then come back and apply this patch later. continue; } } $is_global = $patch->getIsGlobalPatch(); $patch_apis = array_select_keys($api_map, $need_patch); foreach ($patch_apis as $ref_key => $api) { if ($is_global) { // If this is a global patch which we previously applied, just // read the duration from the map without actually applying // the patch. $duration = idx($duration_map, $key); } else { $duration = null; } if ($duration === null) { if ($is_dryrun) { echo tsprintf( "%s\n", pht( 'DRYRUN: Would apply patch "%s" to host "%s".', $key, $ref_key)); } else { echo tsprintf( "%s\n", pht( 'Applying patch "%s" to host "%s"...', $key, $ref_key)); } $t_begin = microtime(true); if (!$is_dryrun) { $api->applyPatch($patch); } $t_end = microtime(true); $duration = ($t_end - $t_begin); $duration_map[$key] = $duration; } // If we're explicitly reapplying this patch, we don't need to // mark it as applied. if (!isset($state_map[$ref_key][$key])) { if (!$is_dryrun) { $api->markPatchApplied($key, ($t_end - $t_begin)); } $applied_map[$ref_key][$key] = true; } } // We applied this everywhere, so we're done with the patch. unset($patches[$key]); $applied_something = true; } if (!$applied_something) { if ($patches) { throw new Exception( pht( 'Some patches could not be applied: %s', implode(', ', array_keys($patches)))); } else if (!$is_dryrun && !$apply_only) { echo pht( 'Storage is up to date. Use "%s" for details.', 'storage status')."\n"; } break; } } } final protected function getBareHostAndPort($host) { // Split out port information, since the command-line client requires a // separate flag for the port. $uri = new PhutilURI('mysql://'.$host); if ($uri->getPort()) { $port = $uri->getPort(); $bare_hostname = $uri->getDomain(); } else { $port = null; $bare_hostname = $host; } return array($bare_hostname, $port); } /** * Acquires a @{class:PhabricatorGlobalLock}. * * @return PhabricatorGlobalLock */ final protected function lock(PhabricatorStorageManagementAPI $api) { // Although we're holding this lock on different databases so it could // have the same name on each as far as the database is concerned, the // locks would be the same within this process. $parameters = array( 'refKey' => $api->getRef()->getRefKey(), ); // We disable logging for this lock because we may not have created the // log table yet, or may need to adjust it. return PhabricatorGlobalLock::newLock('adjust', $parameters) ->useSpecificConnection($api->getConn(null)) ->setDisableLogging(true) ->lock(); } final protected function analyzeTables( PhabricatorStorageManagementAPI $api) { // Analyzing tables can sometimes have a significant effect on query // performance, particularly for the fulltext ngrams tables. See T12819 // for some specific examples. $conn = $api->getConn(null); $patches = $this->getPatches(); $databases = $api->getDatabaseList($patches, true); $this->logInfo( pht('ANALYZE'), pht('Analyzing tables...')); $targets = array(); foreach ($databases as $database) { queryfx($conn, 'USE %C', $database); $tables = queryfx_all($conn, 'SHOW TABLE STATUS'); foreach ($tables as $table) { $table_name = $table['Name']; $targets[] = array( 'database' => $database, 'table' => $table_name, ); } } $bar = id(new PhutilConsoleProgressBar()) ->setTotal(count($targets)); foreach ($targets as $target) { queryfx( $conn, 'ANALYZE TABLE %T.%T', $target['database'], $target['table']); $bar->update(1); } $bar->done(); $this->logOkay( pht('ANALYZED'), pht( 'Analyzed %d table(s).', count($targets))); } }