- <?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.'
- );
- }
- }