<?php
/**
* Abstract class for Perforce Connection implementations.
*
* @copyright 2011 Perforce Software. All rights reserved.
* @license Please see LICENSE.txt in top-level folder of this distribution.
* @version <release>/<patch>
* @todo verify need to call disconnect() on setClient/Password/etc.
*/
namespace P4\Connection;
use P4;
use P4\Connection\Exception\ServiceNotFoundException;
use P4\Counter\Counter;
use P4\Environment\Environment;
use P4\Log\Logger;
use P4\Time\Time;
use P4\Validate;
abstract class AbstractConnection implements ConnectionInterface
{
const LOG_MAX_STRING_LENGTH = 1024;
const DEFAULT_CHARSET = 'utf8unchecked';
protected $client;
protected $info;
protected $password;
protected $port;
protected $ticket;
protected $ticketUnlocked;
protected $user;
protected $charset;
protected $host;
protected $appName;
protected $progName;
protected $progVersion;
protected $services;
protected $disconnectCallbacks = array();
protected $preRunCallbacks = array();
protected $postRunCallbacks = array();
protected $slowCommandLogging = array();
/**
* Create an Interface instance.
*
* @param string $port optional - the port to connect to.
* @param string $user optional - the user to connect as.
* @param string $client optional - the client spec to use.
* @param string $password optional - the password to use.
* @param string $ticket optional - a ticket to use.
*/
public function __construct(
$port = null,
$user = null,
$client = null,
$password = null,
$ticket = null
) {
$this->setPort($port);
$this->setUser($user);
$this->setClient($client);
$this->setPassword($password);
$this->setTicket($ticket);
// ensure we disconnect on shutdown.
Environment::addShutdownCallback(
array($this, 'disconnect')
);
}
/**
* Return the p4 port.
*
* @return string the port.
*/
public function getPort()
{
return $this->port;
}
/**
* Set the p4 port.
* Forces a disconnect if already connected.
*
* @param string $port the port to connect to.
* @return ConnectionInterface provides fluent interface.
* @todo validate port using port validator - make validator work with 'rsh:' ports.
*/
public function setPort($port)
{
$this->port = (string) $port;
// disconnect on port change.
$this->disconnect();
return $this;
}
/**
* Return the name of the p4 user.
*
* @return string the user.
*/
public function getUser()
{
return $this->user;
}
/**
* Set the name of the p4 user.
* Forces a disconnect if already connected.
*
* @param string $user the user to connect as.
* @return ConnectionInterface provides fluent interface.
* @throws P4\Exception if the user is not valid.
*/
public function setUser($user)
{
$validator = new Validate\UserName;
if ($user !== null && !$validator->isValid($user)) {
throw new P4\Exception("Username: " . implode("\n", $validator->getMessages()));
}
$this->user = $user;
// disconnect on user change.
$this->disconnect();
return $this;
}
/**
* Return the p4 user's client.
*
* @return string the client.
*/
public function getClient()
{
return $this->client;
}
/**
* Set the p4 user's client.
* Forces a disconnect if already connected.
*
* @param string $client the name of the client workspace to use.
* @return ConnectionInterface provides fluent interface.
* @throws P4\Exception if the client is not valid.
*/
public function setClient($client)
{
$validator = new Validate\SpecName;
if ($client !== null && !$validator->isValid($client)) {
throw new P4\Exception("Client name: " . implode("\n", $validator->getMessages()));
}
$this->client = $client;
// clear cached p4 info
$this->info = null;
return $this;
}
/**
* Retrieves the password set for this perforce connection.
*
* @return string password used to authenticate against perforce server.
*/
public function getPassword()
{
return $this->password;
}
/**
* Sets the password to use for this perforce connection.
*
* @param string $password the password to use as authentication.
* @return ConnectionInterface provides fluent interface.
*/
public function setPassword($password)
{
$this->password = $password;
return $this;
}
/**
* Retrieves the ticket set for this perforce connection.
*
* @return string ticket as generated by perforce server.
*/
public function getTicket()
{
return $this->ticket;
}
/**
* Check if last login call was to generate a ticket valid for all hosts
*
* @return bool last login call was for an unlocked ticket
*/
public function isTicketUnlocked()
{
return (bool) $this->ticketUnlocked;
}
/**
* Sets the ticket to use for this perforce connection.
* Forces a disconnect if already connected.
*
* @param string $ticket the ticket to use as authentication.
* @return ConnectionInterface provides fluent interface.
*/
public function setTicket($ticket)
{
$this->ticket = $ticket;
// disconnect on ticket change.
$this->disconnect();
return $this;
}
/**
* Retrieves the character set used by this connection.
*
* @return string charset used for this connection.
*/
public function getCharset()
{
return $this->charset;
}
/**
* Sets the character set to use for this perforce connection.
*
* You should only set a character set when connecting to a
* 'unicode enabled' server, or when setting the special value
* of 'none'.
*
* @param string $charset the charset to use (e.g. 'utf8').
* @return ConnectionInterface provides fluent interface.
*/
public function setCharset($charset)
{
$this->charset = $charset;
return $this;
}
/**
* Retrieves the client host set for this connection.
*
* @return string host name used for this connection.
*/
public function getHost()
{
return $this->host;
}
/**
* Sets the client host name overriding the environment.
*
* @param string|null $host the host name to use.
* @return ConnectionInterface provides fluent interface.
*/
public function setHost($host)
{
$this->host = $host;
return $this;
}
/**
* Set the name of the application that is using this connection.
*
* The application name will be reported to the server and might
* be necessary to satisfy certain licensing restrictions.
*
* @param string|null $name the app name to report to the server.
* @return ConnectionInterface provides fluent interface.
*/
public function setAppName($name)
{
$this->appName = is_null($name) ? $name : (string) $name;
return $this;
}
/**
* Get the application name being reported to the server.
*
* @return string|null the app name reported to the server.
*/
public function getAppName()
{
return $this->appName;
}
/**
* Set the name of the program that is using this connection.
*
* The program name will be reported in the server logs
*
* @param string|null $name the program name to report to the server.
* @return ConnectionInterface provides fluent interface.
*/
public function setProgName($name)
{
$this->progName = is_null($name) ? $name : (string) $name;
return $this;
}
/**
* Get the program name being reported to the server.
*
* @return string|null the program name reported to the server.
*/
public function getProgName()
{
return $this->progName;
}
/**
* Set the program version of the program that is using this connection.
*
* The program version will be reported in the server logs
*
* @param string|null $version the program version to report to the server.
* @return ConnectionInterface provides fluent interface.
*/
public function setProgVersion($version)
{
$this->progVersion = is_null($version) ? $version : (string) $version;
return $this;
}
/**
* Get the program version being reported to the server.
*
* @return string|null the program version reported to the server.
*/
public function getProgVersion()
{
return $this->progVersion;
}
/**
* Get the current client's root directory with no trailing slash.
*
* @return string the full path to the current client's root.
*/
public function getClientRoot()
{
$info = $this->getInfo();
if (isset($info['clientRoot'])) {
return rtrim($info['clientRoot'], '/\\');
}
return false;
}
/**
* Return an array of connection information.
* Due to caching, server date may be stale.
*
* @return array the connection information ('p4 info').
*/
public function getInfo()
{
// if info cache is populated and connection is up, return cached info.
if (isset($this->info) && $this->isConnected()) {
return $this->info;
}
// run p4 info.
$result = $this->run("info");
$this->info = array();
// gather all data (multiple arrays returned when connecting through broker).
foreach ($result->getData() as $data) {
$this->info += $data;
}
return $this->info;
}
/**
* Clear the info cache. This method is primarily used during testing,
* and would not normally be used.
*
* @return ConnectionInterface provides fluent interface.
*/
public function clearInfo()
{
$this->info = null;
return $this;
}
/**
* Get the server identity of this connection.
* Resulting array will contain:
* - name
* - platform
* - version
* - build
* - apiversion (same value as version, included for consistency)
* - apibuild (same value as build, included for consistency)
* - date
* - original (all text for server version from 'info' response)
*
* @return array an array of server information for this connection
* @throws P4\Exception if the returned server version string is invalid
*/
public function getServerIdentity()
{
$info = $this->getInfo();
$parts = isset($info['serverVersion'])
? preg_split('/\/| \(|\)/', $info['serverVersion'])
: null;
if (count($parts) < 6) {
$message = 'p4 info returned an invalid server version string';
throw new P4\Exception($message);
}
// build server identity array of version components, including original string
return array(
'name' => $parts[0],
'platform' => $parts[1],
'version' => $parts[2],
'build' => $parts[3],
'apiversion' => $parts[2],
'apibuild' => $parts[3],
'date' => $parts[4] . '/' . $parts[5] . '/' . $parts[6],
'original' => $info['serverVersion']
);
}
/**
* Return perforce server version in the form of '<year>.<release>'.
*
* @return string server version as '<year>.<release>'
* @throws P4\Exception if server version cannot be determined
*/
public function getServerVersion()
{
$identity = $this->getServerIdentity();
$version = $identity['version'];
// keep only '<year>.<release>' of the version
$parts = explode('.', $version);
if (count($parts) < 2) {
throw new P4\Exception(
'Cannot get version from server identity: unknown version format.'
);
}
$version = implode('.', array_slice($parts, 0, 2));
return $version;
}
/**
* Check if the server version for this connection is same or higher than
* the version passed in the parameter.
*
* @param string $version version to compare in format <year>.<release>
* @return bool true if server version is same or higher than $version
*/
public function isServerMinVersion($version)
{
return version_compare($this->getServerVersion(), $version) >= 0;
}
/**
* Check if the P4API version for this connection is same or higher than
* the version passed in the parameter.
*
* @param string $version version to compare in format <year>.<release>
* @return bool true if P4API version is same or higher than $version
* @throws P4\Exception if the apiVersion string is invalid
*/
public function isApiMinVersion($version)
{
$identity = $this->getConnectionIdentity();
$apiVersion = isset($identity['apiversion']) ? $identity['apiversion'] : '';
// keep only '<year>.<release>' of the apiVersion
$parts = explode('.', $apiVersion);
if (count($parts) < 2) {
throw new P4\Exception(
'Cannot get version from connection identity: unknown version format.'
);
}
$apiVersion = implode('.', array_slice($parts, 0, 2));
return version_compare($apiVersion, $version) >= 0;
}
/**
* Check if the user is authenticated
*
* Note: if the user has no password, but one has been set on the connection, we consider that not authenticated.
*
* @return bool true if user is authenticated, false otherwise
*/
public function isAuthenticated()
{
try {
$result = $this->run('login', '-s', null, false);
$result = implode($result->getData());
} catch (Exception\CommandException $e) {
return false;
}
// if a password is not required but one was provided, we should reject that connection on principle
if (strpos("'login' not necessary, no password set for this user.", $result) !== false
&& (strlen($this->password) || strlen($this->ticket))
) {
return false;
}
return true;
}
/**
* Return option limit (server-side limit on the number of flags)
* based on the server version.
*
* @return int option limit
*/
public function getOptionLimit()
{
$limit = 20;
if ($this->isServerMinVersion('2012.1')) {
$limit = 256;
}
return $limit;
}
/**
* Authenticate the user with 'p4 login'.
*
* @param bool|null $all get a ticket valid for all hosts (false by default)
* @return string|null the ticket issued by the server or null if
* no ticket issued (ie. user has no password).
* @throws Exception\LoginException if login fails.
*/
public function login($all = false)
{
// record whether or not caller requested an unlocked ticket
$this->ticketUnlocked = (bool) $all;
// ensure user name is set.
if (!strlen($this->getUser())) {
throw new Exception\LoginException(
"Login failed. Username is empty.",
Exception\LoginException::IDENTITY_AMBIGUOUS
);
}
// try to login.
try {
$result = $this->run('login', $all ? array('-a', '-p') : array('-p'), $this->password ?: '');
} catch (Exception\CommandException $e) {
// user doesn't exist.
if (stristr($e->getMessage(), "doesn't exist") ||
stristr($e->getMessage(), "has not been enabled by 'p4 protect'")
) {
throw new Exception\LoginException(
"Login failed. " . $e->getMessage(),
Exception\LoginException::IDENTITY_NOT_FOUND
);
}
// invalid password.
if (stristr($e->getMessage(), "password invalid")) {
throw new Exception\LoginException(
"Login failed. " . $e->getMessage(),
Exception\LoginException::CREDENTIAL_INVALID
);
}
// generic login exception.
throw new Exception\LoginException(
"Login failed. " . $e->getMessage()
);
}
// we can get several output blocks
// we want the first block that looks like a ticket
// if user has no password, the last block will be a message
// if using external auth, early blocks could be trigger output
// if talking to a replica, the last block will be a ticket for the master
$response = end($result->getData());
foreach ($result->getData() as $data) {
if (preg_match('/^[A-F0-9]{32}$/', $data)) {
$response = $data;
break;
}
}
// check if no password set for this user.
// fail if a password was provided - succeed otherwise.
if (stristr($response, "no password set for this user")) {
if ($this->password) {
throw new Exception\LoginException(
"Login failed. " . $response,
Exception\LoginException::CREDENTIAL_INVALID
);
} else {
return null;
}
}
// capture ticket from output.
$this->ticket = $response;
// if ticket wasn't captured correctly, fail with unknown code.
if (!$this->ticket) {
throw new Exception\LoginException(
"Login failed. Unable to capture login ticket."
);
}
return $this->ticket;
}
/**
* Executes the specified command and returns a perforce result object.
* No need to call connect() first. Run will connect automatically.
*
* Performs common pre/post-run work. Hands off to doRun() for the
* actual mechanics of running commands.
*
* @param string $command the command to run.
* @param array|string $params optional - one or more arguments.
* @param array|string $input optional - input for the command - should be provided
* in array form when writing perforce spec records.
* @param boolean $tagged optional - true/false to enable/disable tagged output.
* defaults to true.
* @param boolean $ignoreErrors optional - true/false to ignore errors - default false
* @return P4\Connection\CommandResult the perforce result object.
*/
public function run(
$command,
$params = array(),
$input = null,
$tagged = true,
$ignoreErrors = false
) {
// establish connection to perforce server.
if (!$this->isConnected()) {
$this->connect();
}
// ensure params is an array.
if (!is_array($params)) {
if (!empty($params)) {
$params = array($params);
} else {
$params = array();
}
}
// log the start of the command w. params.
$message = "P4 (" . spl_object_hash($this) . ") start command: "
. $command . " " . implode(" ", $params);
Logger::log(
Logger::DEBUG,
substr($message, 0, static::LOG_MAX_STRING_LENGTH)
);
// prepare input for passing to perforce.
$input = $this->prepareInput($input, $command);
// run any 'pre-run' callbacks
foreach ($this->preRunCallbacks as $callback) {
$callback($this, $command, $params, $input, $tagged);
}
// defer to sub-classes to actually issue the command.
$start = microtime(true);
$result = $this->doRun($command, $params, $input, $tagged);
$lapse = microtime(true) - $start;
// run any 'post-run' callbacks
foreach ($this->postRunCallbacks as $callback) {
$callback($this, $result);
}
// if the command was slow, log a warning
// we determine the threshold for slow based on the command being run
$slow = 0;
foreach ($this->getSlowCommandLogging() as $key => $value) {
if (!is_array($value) && ctype_digit((string) $value)) {
$slow = max($slow, $value);
} elseif (in_array($command, (array) $value)) {
$slow = max($slow, (int) $key);
}
}
if ($slow && $lapse >= $slow) {
$message = "P4 (" . spl_object_hash($this) . ") slow command (" . round($lapse, 3) . "s): "
. $command . " " . implode(" ", $params);
Logger::log(
Logger::WARN,
substr($message, 0, static::LOG_MAX_STRING_LENGTH)
);
}
// log errors - log them and throw an exception.
if ($result->hasErrors() && !$ignoreErrors) {
// if we have no charset, and the command failed because we are
// talking to a unicode server, automatically use the default
// charset and run the command again.
$errors = $result->getErrors();
$needle = 'Unicode server permits only unicode enabled clients.';
if (!$this->getCharset() && stripos($errors[0], $needle) !== false) {
$this->setCharset(static::DEFAULT_CHARSET);
// run the command again now that we have a charset.
return call_user_func_array(
array($this, 'run'),
func_get_args()
);
}
// if connect failed due to an untrusted server, trust it and retry
$needle = "To allow connection use the 'p4 trust' command";
if (stripos($errors[0], $needle) !== false && !$this->hasTrusted) {
// add a property to avoid re-recursing on this test
$this->hasTrusted = true;
// work around @job066722 by disconnecting to clear the argument buffer
// -fixed in P4API 2013.2
if (!$this->isApiMinVersion('2013.2')) {
$this->disconnect();
}
// trust the connection as this is the first time we have seen it
$this->run('trust', '-y');
// run the command again now that we have trusted it
return call_user_func_array(
array($this, 'run'),
func_get_args()
);
}
$message = "P4 (" . spl_object_hash($this) . ") command failed: "
. implode("\n", $result->getErrors());
Logger::log(
Logger::DEBUG,
substr($message, 0, static::LOG_MAX_STRING_LENGTH)
);
$this->handleError($result);
}
return $result;
}
/**
* Runs the specified command using the passed output handler.
* Ensures the output handler is turned back off at completion.
*
* If the handler has a 'reset' method it will be called. This is intended
* to give the handler an opportunity to prepare itself for a fresh run.
*
* @param P4_OutputHandlerAbstract $handler the output handler to use
* @param string $command the command to run.
* @param array|string $params optional - one or more arguments.
* @param array|string $input optional - input for the command - should be provided
* in array form when writing perforce spec records.
* @param boolean $tagged optional - true/false to enable/disable tagged output.
* defaults to true.
* @param boolean $ignoreErrors optional - true/false to ignore errors - default false
* @throws \P4\Exception if the implementation doesn't define a runHandler
* @return \P4\Connection\CommandResult the perforce result object.
*/
public function runHandler(
$handler,
$command,
$params = array(),
$input = null,
$tagged = true,
$ignoreErrors = false
) {
throw new P4\Exception('Implementing class must define a runHandler implementation!');
}
/**
* Check if the user we are connected as has super user privileges.
*
* @return bool true if the user has super, false otherwise.
* @throws Exception\CommandException if unanticipated error from protects -m.
*/
public function isSuperUser()
{
return $this->getMaxAccess() === 'super';
}
/**
* Check if the user we are connected as has admin user privileges.
* By default, 'super' connection will return false on this check.
* This behaviour can be modified by optional $allowSuper flag
* to also include 'super' users.
*
* @param bool $allowSuper optional - if true, then this check will
* return true also if the connection is super
* @return bool true if the user is admin (or super if $allowSuper is true),
* false otherwise.
* @throws Exception\CommandException if unanticipated error from protects -m.
*/
public function isAdminUser($allowSuper = false)
{
$maxAccess = $this->getMaxAccess();
return ($allowSuper && $maxAccess === 'super') || $maxAccess === 'admin';
}
/**
* Check if the server we are connected to is case sensitive.
*
* @return bool true if the server is case sensitive, false otherwise.
* @throws P4\Exception if unable to determine server case handling.
*/
public function isCaseSensitive()
{
$info = $this->getInfo();
// throw exception if case handling unknown.
if (!isset($info['caseHandling'])) {
throw new P4\Exception("Cannot determine server case-handling.");
}
return $info['caseHandling'] === 'sensitive';
}
/**
* Tests if a candidate matches any of the provided values accounting for case sensitivity.
* If the server is case insensitive so is this; otherwise case sensitive.
*
* @param string $candidate the value to test for a match
* @param string|array $values one or more values to compare to
* @return bool true if the candidate matches any of the provided values
*/
public function stringMatches($candidate, $values)
{
$values = (array) $values;
// if the server is case insensitive; lowercase everything.
if (!$this->isCaseSensitive()) {
$candidate = strtolower($candidate);
$values = array_map('strtolower', $values);
}
return in_array($candidate, $values);
}
/**
* Check if the server we are connected to is using external authentication
*
* @return bool true if the server is using external authentication, false otherwise.
*/
public function hasExternalAuth()
{
$info = $this->getInfo();
if (isset($info['externalAuth']) && ($info['externalAuth'] === 'enabled')) {
return true;
}
return false;
}
/**
* Check if the server we are connected to has a auth-set trigger configured.
*
* @return bool true, if the server has configured an auth-set trigger,
* false, otherwise.
*/
public function hasAuthSetTrigger()
{
// exit early if the server is not using external authentication
if (!$this->hasExternalAuth()) {
return false;
}
try {
// try to set the password, the server without an auth-set trigger
// throws a Exception\CommandException with the error message:
// "Command unavailable: external authentication 'auth-set' trigger not found."
$this->run('passwd');
} catch (Exception\CommandException $e) {
if (stristr($e->getMessage(), "'auth-set' trigger not found.")) {
return false;
}
}
return true;
}
/**
* Connect to a Perforce Server.
* Hands off to doConnect() for the actual mechanics of connecting.
*
* @return ConnectionInterface provides fluent interface.
* @throws Exception\ConnectException if the connection fails.
*/
public function connect()
{
if (!$this->isConnected()) {
// refuse to connect if no port or no user set.
if (!strlen($this->getPort()) || !strlen($this->getUser())) {
throw new Exception\ConnectException(
"Cannot connect. You must specify both a port and a user."
);
}
$this->doConnect();
}
return $this;
}
/**
* Run disconnect callbacks.
*
* @return ConnectionInterface provides fluent interface.
*/
public function disconnect()
{
return $this->runDisconnectCallbacks();
}
/**
* Add a function to run when connection is closed.
* Callbacks are removed after they are executed
* unless persistent is set to true.
*
* @param callable $callback the function to execute on disconnect
* (will be passed connection).
* @param bool $persistent optional - defaults to false - set to true to
* run callback on repeated disconnects.
* @return ConnectionInterface provides fluent interface.
* @throws \InvalidArgumentException if callback supplied is not callable
*/
public function addDisconnectCallback($callback, $persistent = false)
{
if (!is_callable($callback)) {
throw new \InvalidArgumentException(
"Cannot add disconnect callback. Not callable"
);
}
$this->disconnectCallbacks[] = array(
'callback' => $callback,
'persistent' => $persistent
);
return $this;
}
/**
* Clear disconnect callbacks.
*
* @return ConnectionInterface provides fluent interface.
*/
public function clearDisconnectCallbacks()
{
$this->disconnectCallbacks = array();
return $this;
}
/**
* Run disconnect callbacks.
*
* @return ConnectionInterface provides fluent interface.
*/
public function runDisconnectCallbacks()
{
foreach ($this->disconnectCallbacks as $key => $callback) {
call_user_func($callback['callback'], $this);
if (!$callback['persistent']) {
unset($this->disconnectCallbacks[$key]);
}
}
return $this;
}
/**
* Add a function to call immediately before commands are run.
*
* @param callable $callback the function to execute just prior to running commands.
* args are: $connection, $command, $params, $input, $tagged
* @return ConnectionInterface provides fluent interface.
*/
public function addPreRunCallback($callback)
{
if (!is_callable($callback)) {
throw new \InvalidArgumentException(
"Cannot add pre-run callback. Not callable"
);
}
$this->preRunCallbacks[] = $callback;
return $this;
}
/**
* Add a function to call immediately after commands are run.
*
* @param callable $callback the function to execute just after running commands.
* args are: $connection, $result
* @return ConnectionInterface provides fluent interface.
*/
public function addPostRunCallback($callback)
{
if (!is_callable($callback)) {
throw new \InvalidArgumentException(
"Cannot add post-run callback. Not callable"
);
}
$this->postRunCallbacks[] = $callback;
return $this;
}
/**
* Get the server's security level.
*
* @return int the security level of the server (e.g. 0, 1, 2, 3)
*/
public function getSecurityLevel()
{
if (!Counter::exists('security', $this)) {
return 0;
}
return (int) Counter::fetch('security', $this)->get();
}
/**
* This function will throw the appropriate exception for the error(s) found
* in the passed result object.
*
* @param P4\Connection\CommandResult $result The result containing errors
* @throws Exception\ConflictException if there are file conflicts to resolve.
* @throws Exception\CommandException if there are any other command errors.
*/
public function handleError($result)
{
$message = "Command failed: " . implode("\n", $result->getErrors());
// create appropriate exception based on error condition
if (preg_match("/must (sync\/ )?(be )?resolved?/", $message) ||
preg_match("/Merges still pending/", $message)) {
$e = new Exception\ConflictException($message);
} else {
$e = new Exception\CommandException($message);
}
$e->setConnection($this);
$e->setResult($result);
throw $e;
}
/**
* Get the maximum allowable length of all command arguments.
*
* @return int the max length of combined arguments - zero for no limit
*/
public function getArgMax()
{
return 0;
}
/**
* Return arguments split into chunks (batches) where each batch contains as many
* arguments as possible to not exceed ARG_MAX or option limit.
*
* ARG_MAX is a character limit that affects command line programs (p4).
* Option limit is a server-side limit on the number of flags (e.g. '-n').
*
* @param array $arguments list of arguments to split into chunks.
* @param array|null $prefixArgs arguments to begin all batches with.
* @param array|null $suffixArgs arguments to end all batches with.
* @param int $groupSize keep arguments together in groups of this size
* for example, when clearing attributes you want to
* keep pairs of -n and attr-name together.
* @return array list of batches of arguments where every batch contains as many
* arguments as possible and arg-max is not exceeded.
* @throws P4\Exception if a argument (or set of arguments) exceed arg-max.
*/
public function batchArgs(array $arguments, array $prefixArgs = null, array $suffixArgs = null, $groupSize = 1)
{
// determine size of leading and trailing arguments.
$initialLength = 0;
$initialOptions = 0;
$argMax = $this->getArgMax();
$optionLimit = $this->getOptionLimit();
$prefixArgs = (array) $prefixArgs;
$suffixArgs = (array) $suffixArgs;
foreach (array_merge($prefixArgs, $suffixArgs) as $argument) {
// if we have an arg-max limit, determine length of common args.
// compute length by adding length of escaped argument + 1 space
if ($argMax) {
$initialLength += strlen(static::escapeArg($argument)) + 1;
}
// if the first character is a dash ('-'), it's an option
if (substr($argument, 0, 1) === '-') {
$initialOptions++;
}
}
$batches = array();
while (!empty($arguments)) {
// determine how many arguments we can move into this batch.
$count = 0;
$length = $initialLength;
$options = $initialOptions;
foreach ($arguments as $argument) {
// if we have an arg-max limit, enforce it.
// compute length by adding length of escaped argument + 1 space
if ($argMax) {
$length += strlen(static::escapeArg($argument)) + 1;
// if we exceed arg-max, break
if ($length >= $argMax) {
break;
}
}
// if we exceed the option-limit, break
if ($options > $optionLimit) {
break;
}
// if the first character is a dash ('-'), it's an option
if (substr($argument, 0, 1) === '-') {
$options++;
}
$count++;
}
// adjust count down to largest divisible group size
// and move that number of arguments into this batch.
$count -= $count % $groupSize;
$batches[] = array_merge($prefixArgs, array_splice($arguments, 0, $count), $suffixArgs);
// handle the case of a given argument group not fitting in a batch
// this informs the caller of indivisble args and avoids infinite loops
if (!empty($arguments) && $count < $groupSize) {
throw new P4\Exception(
"Cannot batch arguments. Arguments exceed arg-max and/or option-limit."
);
}
}
return $batches;
}
/**
* Escape a string for use as a command argument.
* Escaping is a no-op for the abstract implementation,
* but is needed by batchArgs.
*
* @param string $arg the string to escape
* @return string the escaped string
*/
public static function escapeArg($arg)
{
return $arg;
}
/**
* Get the server's timezone.
*
* @return DateTimeZone the server's timezone
* @throws \Exception if the server's timezone isn't parsable
*/
public function getTimeZone()
{
// the 'serverDate' object lists the offset in the format -0800 and the short timezone name as the
// last two components. strip these off and convert to a long name if possible.
$info = $this->getInfo();
if (isset($info['serverDate'])
&& preg_match('#^[0-9/]+ [0-9:]+ (?P<offset>[0-9-+]+) (?P<name>.+)$#', $info['serverDate'], $timezone)
) {
// converting the string to a DateTimeZone is tricky; outsource the heavy lifting
return Time::toDateTimeZone($timezone['name'], $timezone['offset']);
}
// if we couldn't preg match out the details; throw
throw new \Exception('Unable to get timezone, p4 info does not contain a parsable serverDate');
}
/**
* Attach a service to this connection.
* Allows the connection to act as a service locator (e.g. for logging, caching, etc.)
*
* @param string $name the name of the service to set (e.g. 'cache')
* @param object|callable|null $service the service instance or factory function (or null to clear)
* factory is called with the connection and service name
* @return ConnectionInterface to maintain a fluent interface
* @throws \InvalidArgumentException if the name or service is invalid
*/
public function setService($name, $service)
{
if (!is_string($name) || !strlen($name)) {
throw new \InvalidArgumentException("Cannot set service. Name must be a non-empty string.");
}
// if service is null, remove it
if ($service === null) {
unset($this->services[$name]);
return $this;
}
if (!is_object($service) && !is_callable($service)) {
throw new \InvalidArgumentException("Cannot set service. Service must be an object or callable.");
}
$this->services[$name] = array(
'instance' => is_callable($service) ? null : $service,
'factory' => is_callable($service) ? $service : null
);
return $this;
}
/**
* Retrieve a service from this connection.
*
* @param string $name the name of the service to get (e.g. 'cache')
* @return object the service instance (factory functions are resolved automatically)
* @throws ServiceNotFoundException if the requested service does not exist
*/
public function getService($name)
{
if (!isset($this->services[$name])) {
throw new ServiceNotFoundException("Cannot get service. No such service ('$name').");
}
// construct the service instance if necessary
$service = $this->services[$name];
if (!isset($service['instance'])) {
$service['instance'] = $service['factory']($this, $name);
$this->services[$name] = $service;
}
return $service['instance'];
}
/**
* Set the threshold(s) for logging slow commands.
* Pass false or an empty array to disable logging.
*
* You may specify a default limit (in seconds) as well as limits that
* apply to only specific commands. The longest applicable limit is used
* for a given command if more than one candidate occurs.
*
* The format is:
* $limits => array(
* 3, // numeric value is a default (any command) limit
* 30 => array('print', 'submit') // seconds as key with command(s) as value for command specific limit
* 60 => 'unshelve'
* );
*
* In the above example, the command fstat would have a limit of 3, print 30 and unshelve 60.
*
* @param array|bool $thresholds the limit(s) to trigger slow command logging or false
* @return ConnectionInterface to maintain a fluent interface
*/
public function setSlowCommandLogging($thresholds)
{
$this->slowCommandLogging = (array) $thresholds;
return $this;
}
/**
* Return the currently specified slow command thresholds.
*
* @return array the slow command thresholds, see setSlowCommandLimits for format details
*/
public function getSlowCommandLogging()
{
return (array) $this->slowCommandLogging;
}
/**
* Get maximum access level for this connection.
*
* @param string|null $host optional - if set, max access level will be determined
* for the given host
* @return string|false maximum access level or false
*/
public function getMaxAccess($host = null)
{
// get max access level from Perforce
$flags = array('-m');
if ($host) {
$flags[] = '-h';
$flags[] = $host;
}
try {
$result = $this->run("protects", $flags);
} catch (Exception\CommandException $e) {
// if protections table is empty, everyone is super
$errors = $e->getResult()->getErrors();
if (stristr($errors[0], "empty")) {
return 'super';
} elseif (stristr($errors[0], "password must be set")) {
return false;
}
throw $e;
}
return $result->getData(0, "permMax");
}
/**
* Actually issues a command. Called by run() to perform the dirty work.
*
* @param string $command the command to run.
* @param array $params optional - arguments.
* @param array|string $input optional - input for the command - should be provided
* in array form when writing perforce spec records.
* @param boolean $tagged optional - true/false to enable/disable tagged output.
* defaults to true.
* @return P4\Connection\CommandResult the perforce result object.
*/
abstract protected function doRun($command, $params = array(), $input = null, $tagged = true);
/**
* Prepare input for passing to Perforce.
*
* @param string|array $input the input to prepare for p4.
* @param string $command the command to prepare input for.
* @return string|array the prepared input.
*/
abstract protected function prepareInput($input, $command);
/**
* Does real work of establishing connection. Called by connect().
*
* @throws Exception\ConnectException if the connection fails.
*/
abstract protected function doConnect();
}