<?php /** * Abstracts operations against Perforce user groups. * * Abandon all hope ye who go beyond this point. * * Groups is a bit of an odd duck. Identified un-expected behaviour includes: * - "group -i" with no populated users/owners/subgroups will report 'created' but it isn't * - "groups" output is unusually formatted; see Pural Abstract for details * * @copyright 2011 Perforce Software. All rights reserved. * @license Please see LICENSE.txt in top-level folder of this distribution. * @version <release>/<patch> */ namespace P4\Spec; use P4\Connection\ConnectionInterface; use P4\Model\Fielded\Iterator as FieldedIterator; use P4\OutputHandler\Limit as LimitHandler; use P4\Spec\Exception\Exception; use P4\Validate; class Group extends PluralAbstract { const SPEC_TYPE = 'group'; const ID_FIELD = 'Group'; const FETCH_BY_MEMBER = 'member'; const FETCH_BY_USER = 'user'; const FETCH_INDIRECT = 'indirect'; const FETCH_BY_NAME = 'name'; const FETCH_FILTER_CALLBACK = 'filterCallback'; protected $fields = array( 'MaxResults' => array( 'accessor' => 'getMaxResults', 'mutator' => 'setMaxResults' ), 'MaxScanRows' => array( 'accessor' => 'getMaxScanRows', 'mutator' => 'setMaxScanRows' ), 'MaxLockTime' => array( 'accessor' => 'getMaxLockTime', 'mutator' => 'setMaxLockTime' ), 'Timeout' => array( 'accessor' => 'getTimeout', 'mutator' => 'setTimeout' ), 'PasswordTimeout' => array( 'accessor' => 'getPasswordTimeout', 'mutator' => 'setPasswordTimeout' ), 'Subgroups' => array( 'accessor' => 'getSubgroups', 'mutator' => 'setSubgroups' ), 'Owners' => array( 'accessor' => 'getOwners', 'mutator' => 'setOwners' ), 'Users' => array( 'accessor' => 'getUsers', 'mutator' => 'setUsers' ) ); /** * Get all Groups from Perforce. Adds filtering options. * The groups command produces very unique output - we take over parent to handle it here. * * @param array $options optional - array of options to augment fetch behavior. * supported options are: * FETCH_MAXIMUM - set to integer value to limit to the first * 'max' number of entries. * *Note: Limits imposed client side. * FETCH_BY_MEMBER - get groups containing passed group or * user (no wildcards). * FETCH_BY_USER - get groups containing passed user (no wildcards). * FETCH_INDIRECT - used with FETCH_BY_MEMBER or FETCH_BY_USER * to also list indirect matches. * FETCH_BY_NAME - get the named group. essentially a 'fetch' * but performed differently (no wildcards). * *Note: not compatible with FETCH_BY_MEMBER * FETCH_BY_USER or FETCH_INDIRECT * FETCH_FILTER_CALLBACK - function that takes group array and returns true * or false to include/exclude the group from result * * @param ConnectionInterface $connection optional - a specific connection to use. * @return FieldedIterator all groups satisfying fetch options */ public static function fetchAll($options = array(), ConnectionInterface $connection = null) { // if no connection given, use default. $connection = $connection ?: static::getDefaultConnection(); // normalize options we care about $options = (array) $options + array( static::FETCH_MAXIMUM => 0, static::FETCH_BY_MEMBER => null, static::FETCH_BY_USER => null, static::FETCH_FILTER_CALLBACK => null, ); $options[static::FETCH_MAXIMUM] = (int) $options[static::FETCH_MAXIMUM]; // if a callback is given, ensure it's callable if ($options[static::FETCH_FILTER_CALLBACK] && !is_callable($options[static::FETCH_FILTER_CALLBACK])) { throw new \InvalidArgumentException("Filter callback must be callable or null."); } // fetch all specs using an output handler to minimize memory usage // // 'groups' produces data-blocks for each user/sub-group/owner, but no // data-blocks for the actual groups, this results in a lot of redundant // information and it means we need to collate groups ourselves // // example data-block: // array( // 'user' => 'tester', // 'group' => 'test', // 'isSubGroup' => '0', // 'isOwner' => '0', // 'isUser' => '1', // 'maxResults' => '0', // 'maxScanRows' => '0', // 'maxLockTime' => '0', // 'timeout' => '43200', // 'passTimeout' => '0', // 'isValidUser' => '1', // ) // // the users/sub-groups/owners for a given group are output consecutively // as soon as we capture an entire group, we invoke the filter callback // (if one was specified) and append/skip the group as appropriate $handler = new LimitHandler; $groups = new FieldedIterator; $group = array(); $count = 0; $max = $options[static::FETCH_MAXIMUM]; $filter = $options[static::FETCH_FILTER_CALLBACK]; $handler->setOutputCallback( function ($data, $type) use ($groups, &$group, &$count, $options, $max, $filter, $connection) { // stop processing if we hit the maximum number of groups if ($max && $count >= $max) { return LimitHandler::HANDLER_CANCEL | LimitHandler::HANDLER_HANDLED; } // skip unexpected data blocks // sometimes 'p4 groups' reports a null group (due to job037630), just ignore it if (!is_array($data) || $type !== 'stat' || !strlen($data['group'])) { return LimitHandler::HANDLER_HANDLED; } // if we have hit a new group, process the previous one if ($group && $data['group'] !== $group['Group']) { if (!$filter || $filter($group)) { $spec = new Group($connection); $spec->setRawValues($group) ->deferPopulate(); $groups[$spec->getId()] = $spec; $count++; } $group = array(); } // defer to lazy load if FETCH_BY_MEMBER or FETCH_BY_USER option // was used as result data doesn't contain all the values if ($options[Group::FETCH_BY_MEMBER] || $options[Group::FETCH_BY_USER]) { $group = array('Group' => $data['group']); return LimitHandler::HANDLER_HANDLED; } // setup the group if we haven't already done so if (!$group) { $group = array( 'Group' => $data['group'], 'MaxResults' => Group::normalizeMaxValue($data['maxResults']), 'MaxScanRows' => Group::normalizeMaxValue($data['maxScanRows']), 'MaxLockTime' => Group::normalizeMaxValue($data['maxLockTime']), 'Timeout' => Group::normalizeMaxValue($data['timeout']), 'PasswordTimeout' => Group::normalizeMaxValue($data['passTimeout']), 'Subgroups' => array(), 'Owners' => array(), 'Users' => array() ); } // this data-block represents a user, owner and/or sub-group (can be multiple) if ($data['isSubGroup']) { $group['Subgroups'][] = $data['user']; } if ($data['isOwner']) { $group['Owners'][] = $data['user']; } if ($data['isUser']) { $group['Users'][] = $data['user']; } return LimitHandler::HANDLER_HANDLED; } ); $command = static::getFetchAllCommand(); $flags = static::getFetchAllFlags($options); $connection->runHandler($handler, $command, $flags); // handle the last group if ($group && (!$max || $count < $max) && (!$filter || $filter($group))) { $spec = new Group($connection); $spec->setRawValues($group) ->deferPopulate(); $groups[$spec->getId()] = $spec; } return $groups; } /** * Save this spec to Perforce. Extend parent to throw if group is 'empty' * * @param bool $editAsOwner save the group as a group owner * @param bool $addAsAdmin pass -A to allow admin's to add. * @return SpecAbstract provides a fluent interface * @throws Exception if group is empty */ public function save($editAsOwner = false, $addAsAdmin = false) { // check server version to see if addAsAdmin is supported if ($addAsAdmin && !$this->getConnection()->isServerMinVersion('2012.1')) { throw new Exception('Cannot add group as admin on server versions < 2012.1'); } if ($this->isEmpty()) { throw new Exception("Cannot save. Group is empty."); } // ensure all required fields have values. $this->validateRequiredFields(); $flags = array('-i'); if ($editAsOwner) { $flags[] = '-a'; } if ($addAsAdmin) { $flags[] = '-A'; } $this->getConnection()->run( static::SPEC_TYPE, $flags, $this->getRawValues() ); // should re-populate (server may change values). $this->deferPopulate(true); return $this; } /** * Remove this group from Perforce. * Extended to support the -a flag so that users can delete groups they own. * * @return SpecAbstract provides a fluent interface * @throws Exception if no id has been set. */ public function delete() { return parent::delete($this->getConnection()->isSuperUser() ? null : array('-a')); } /** * Determine if the given group id exists. * * @param string $id the id to check for. * @param ConnectionInterface $connection optional - a specific connection to use. * @return bool true if the given id matches an existing group. */ public static function exists($id, ConnectionInterface $connection = null) { // check id for valid format if (!static::isValidId($id)) { return false; } $groups = static::fetchAll( array( static::FETCH_BY_NAME => $id, static::FETCH_MAXIMUM => 1 ), $connection ); return (bool) count($groups); } /** * Determines if this group is 'empty'. * * A group is considered empty if no entries are present in: * -SubGroups * -Owners * -Users * * Values in Group (id), MaxResults, MaxScanRows, MaxLockTime do not * count towards 'emptiness'. * * @return bool True if group is empty, False otherwise */ public function isEmpty() { $entries = count($this->get('Subgroups')) + count($this->get('Owners')) + count($this->get('Users')); return !(bool) $entries; } /** * The maximum number of results that members of this group can access * from the server from a single command. The default value is null. * * Will be an integer >0, null (if 'unset') or the string 'unlimited' * * @return null|int|string Null if unset, integer >0 or 'unlimited' */ public function getMaxResults() { return $this->getMaxValue('MaxResults'); } /** * Set the MaxResults for this group. See getMaxResults for more info. * * The string 'unset' may be passed in place of null for convienence. * * @param null|int|string $max null (or 'unset'), integer >0 or 'unlimited' * @return Group provides fluent interface. */ public function setMaxResults($max) { return $this->setMaxValue('MaxResults', $max); } /** * The maximum number of rows that members of this group can scan from * the server from a single command. The default value is null. * * Will be an integer >0, null (if 'unset') or the string 'unlimited' * * @return null|int|string Null if unset, integer >0 or 'unlimited' */ public function getMaxScanRows() { return $this->getMaxValue('MaxScanRows'); } /** * Set the MaxScanRows for this group. See getMaxScanRows for more info. * * The string 'unset' may be passed in place of null for convienence. * * @param null|int|string $max null (or 'unset'), integer >0 or 'unlimited' * @return Group provides fluent interface. */ public function setMaxScanRows($max) { return $this->setMaxValue('MaxScanRows', $max); } /** * The maximum length of time (in milliseconds) that any one operation can * lock any database table when scanning data. The default value is null. * * Will be an integer >0, null (if 'unset') or the string 'unlimited' * * @return null|int|string Null if unset, integer >0 or 'unlimited' */ public function getMaxLockTime() { return $this->getMaxValue('MaxLockTime'); } /** * Set the MaxLockTime for this group. See getMaxLockTime for more info. * * The string 'unset' may be passed in place of null for convienence. * * @param null|int|string $max null (or 'unset'), integer >0 or 'unlimited' * @return Group provides fluent interface. */ public function setMaxLockTime($max) { return $this->setMaxValue('MaxLockTime', $max); } /** * The duration (in seconds) of the validity of a session ticket created * by p4 login. The default value is 43200 seconds (12 hours). * For tickets that do not expire, will return 'unlimited'. * * Will be an integer >0, null (if 'unset') or the string 'unlimited' * * @return null|int|string Null if unset, integer >0 or 'unlimited' */ public function getTimeout() { return $this->getMaxValue('Timeout'); } /** * Set the Timeout for this group. See getTimeout for more info. * * The string 'unset' may be passed in place of null for convenience. * * @param null|int|string $timeout null (or 'unset'), integer >0 or 'unlimited' * @return Group provides fluent interface. */ public function setTimeout($timeout) { return $this->setMaxValue('Timeout', $timeout); } /** * The duration (in seconds) of the validity of a password (default is unset). * Will be an integer >0, null (if 'unset') or the string 'unlimited' * * @return null|int|string null if unset, integer >0 or 'unlimited' */ public function getPasswordTimeout() { return $this->getMaxValue('PasswordTimeout'); } /** * Set the PasswordTimeout for this group. See getPasswordTimeout for more info. * * The string 'unset' may be passed in place of null for convenience. * * @param null|int|string $timeout null (or 'unset'), integer >0 or 'unlimited' * @return Group provides fluent interface. */ public function setPasswordTimeout($timeout) { return $this->setMaxValue('PasswordTimeout', $timeout); } /** * Returns the sub-groups for this group. * * @return array subgroups belonging to this group */ public function getSubgroups() { return $this->getRawValue('Subgroups') ?: array(); } /** * Set the sub-groups for this group. * Expects an array containing group names or Group objects. * * @param array $subgroups array of group names or Group objects * @return Group provides fluent interface. */ public function setSubgroups($subgroups) { if (!is_array($subgroups)) { throw new \InvalidArgumentException( 'Subgroups must be specified as an array.' ); } foreach ($subgroups as &$group) { // normalize to strings if ($group instanceof Group) { $group = $group->getId(); } if (!static::isValidId($group)) { throw new \InvalidArgumentException( 'Individual sub-groups must be a valid ID in either string or P4\Spec\Group format.' ); } } return $this->setRawValue('Subgroups', $subgroups); } /** * Adds the passed group to the end of the current sub-groups. * * @param string|Group $group new group to add * @return Group provides fluent interface. */ public function addSubgroup($group) { $subgroups = $this->getSubgroups(); $subgroups[] = $group; return $this->setSubgroups($subgroups); } /** * Returns the owners for this group. * * @return array owners belonging to this group */ public function getOwners() { return $this->getRawValue('Owners') ?: array(); } /** * Set the owners for this group. * Expects an array containing user names or User objects. * * @param array $owners array of user names or User objects * @return Group provides fluent interface. */ public function setOwners($owners) { if (!is_array($owners)) { throw new \InvalidArgumentException( 'Owners must be specified as an array.' ); } foreach ($owners as &$owner) { // normalize to strings if ($owner instanceof User) { $owner = $owner->getId(); } if (!static::isValidUserId($owner)) { throw new \InvalidArgumentException( 'Individual owners must be a valid ID in either string or P4\Spec\User format.' ); } } return $this->setRawValue('Owners', $owners); } /** * Adds the passed owner to the end of the current owners. * * @param string|User $owner new owner to add * @return Group provides fluent interface. */ public function addOwner($owner) { $owners = $this->getOwners(); $owners[] = $owner; return $this->setOwners($owners); } /** * Returns the users for this group. * * @return array users belonging to this group */ public function getUsers() { return $this->getRawValue('Users') ?: array(); } /** * Set the users for this group. * Expects an array containing user names or User objects. * * @param array $users array of user names or User objects * @return Group provides fluent interface. */ public function setUsers($users) { if (!is_array($users)) { throw new \InvalidArgumentException( 'Users must be specified as an array.' ); } foreach ($users as &$user) { // normalize to strings if ($user instanceof User) { $user = $user->getId(); } if (!static::isValidUserId($user)) { throw new \InvalidArgumentException( 'Individual users must be a valid ID in either string or P4\Spec\User format.' ); } } return $this->setRawValue('Users', $users); } /** * Adds the passed user to the end of the current users. * * @param string|User $user new user to add * @return Group provides fluent interface. */ public function addUser($user) { $users = $this->getUsers(); $users[] = $user; return $this->setUsers($users); } /** * Normalize 'max' style field to convert null/0 to 'unset' and -1 to 'unlimited'. * * @param mixed $max the value to attempt normalization on * @return mixed the normalized value if it was null */ public static function normalizeMaxValue($max) { if ($max === null || $max === 0 || $max === '0') { return 'unset'; } if ($max === -1 || $max === '-1') { return 'unlimited'; } // numbers from perforce come back as strings, make them ints if ($max == (string)(int)$max) { return (int)$max; } return $max; } /** * Get the value for a 'max' style field * (one of MaxResults, MaxScanRows, MaxLockTime and Timeout). * * @param string $field Name of the field to get the value from * @return null|int|string null (if 'unset'), integer >0 or 'unlimited' */ protected function getMaxValue($field) { $max = $this->getRawValue($field); // translate the string 'unset' to null if ($max === 'unset') { return null; } // integers come back from perforce as strings // casting to an int, then back to a string screens out non-digit // characters and allows for a 'pure digit' check. if ($max == (string)(int)$max) { return (int)$max; } return $max; } /** * Check if the given id is in a valid format for group specs. * * @param string $id the id to check * @return bool true if id is valid, false otherwise */ protected static function isValidId($id) { $validator = new Validate\GroupName; return $validator->isValid($id); } /** * Check if the given id is in a valid format for user specs. * * @param string $id the id to check * @return bool true if id is valid, false otherwise */ protected static function isValidUserId($id) { $validator = new Validate\UserName; return $validator->isValid($id); } /** * Set the value for a 'max' style field * (one of MaxResults, MaxScanRows, MaxLockTime, Timeout and PasswordTimeout). * * Valid 'max' inputs are: * -null and 0, get converted to 'unset' * -negative 1, gets converted to 'unlimited' * -the string 'unset' * -an integer greater than 0 * -the string 'unlimited' * * @param string $field Name of the field to set value on * @param null|int|string $max null (or 'unset'), integer >0 or 'unlimited' * @return Group provides a fluent interface * @throws \InvalidArgumentException If input is of incorrect type of format */ protected function setMaxValue($field, $max) { // ensure input is in the ballpark if (!is_null($max) && !is_int($max) && !is_string($max)) { throw new \InvalidArgumentException( "Type of input must be one of: null, int, string" ); } // handle null, 0 and -1 $max = static::normalizeMaxValue($max); // verify string format input matches expected value if (is_string($max) && $max !== 'unlimited' && $max !== 'unset') { throw new \InvalidArgumentException( "For string input, only the values 'unlimited' and 'unset' are valid." ); } // ensure integer input is greater than zero if (is_int($max) && $max <= 0) { throw new \InvalidArgumentException( "For integer input, only values greater than zero are valid." ); } return $this->setRawValue($field, $max); } /** * Produce set of flags for the spec list command, given fetch all options array. * Extends parent to add support for filter option. * * @param array $options array of options to augment fetch behavior. * see fetchAll for documented options. * @return array set of flags suitable for passing to spec list command. */ protected static function getFetchAllFlags($options) { // clear FETCH_MAXIMUM if present as we handle it manually unset($options[static::FETCH_MAXIMUM]); $flags = parent::getFetchAllFlags($options); if (isset($options[static::FETCH_BY_NAME])) { $name = $options[static::FETCH_BY_NAME]; if (!static::isValidId($name) && !static::isValidUserId($name)) { throw new \InvalidArgumentException( 'Filter by Name expects a valid group id.' ); } if (isset($options[static::FETCH_INDIRECT]) || isset($options[static::FETCH_BY_MEMBER]) ) { throw new \InvalidArgumentException( 'Filter by Name is not compatible with Fetch by Member or Fetch Indirect.' ); } $flags[] = '-v'; $flags[] = $name; } if (isset($options[static::FETCH_BY_MEMBER], $options[static::FETCH_BY_USER])) { throw new \InvalidArgumentException( 'You cannot specify both fetch by user and fetch by member.' ); } if (isset($options[static::FETCH_INDIRECT]) && (isset($options[static::FETCH_BY_MEMBER]) || isset($options[static::FETCH_BY_USER])) ) { $flags[] = '-i'; } if (isset($options[static::FETCH_BY_USER])) { $user = $options[static::FETCH_BY_USER]; if (!static::isValidUserId($user)) { throw new \InvalidArgumentException( 'Filter by User expects a valid username.' ); } $flags[] = '-u'; $flags[] = $user; } if (isset($options[static::FETCH_BY_MEMBER])) { $member = $options[static::FETCH_BY_MEMBER]; if (!static::isValidId($member) && !static::isValidUserId($member)) { throw new \InvalidArgumentException( 'Filter by Member expects a valid group or username.' ); } $flags[] = $member; } return $flags; } /** * This function is not utilized by Group as our result format is incompatible. * Any attempt to call this function results in an exception. * * @param array $listEntry a single spec entry from spec list output. * @param array $flags the flags that were used for this 'fetchAll' run. * @param ConnectionInterface $connection a specific connection to use. * @throws \BadFunctionCallException On any use of this function in this class. */ protected static function fromSpecListEntry($listEntry, $flags, ConnectionInterface $connection) { throw new \BadFunctionCallException( 'From Spec List Entry is not implemented in the P4\Spec\Group class.' ); } }