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