- <?php
- /**
- * This class layers support for plural specs such as changes, jobs,
- * users, etc. on top of the singular spec support already present
- * in P4\Spec\SingularAbstract.
- *
- * @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;
- use P4\Validate;
- use P4\Spec\Exception\Exception;
- use P4\Spec\Exception\NotFoundException;
- use P4\Connection\ConnectionInterface;
- use P4\Model\Fielded\Iterator as FieldedIterator;
- use P4\OutputHandler\Limit;
-
- abstract class PluralAbstract extends SingularAbstract
- {
- const ID_FIELD = null;
- const FETCH_MAXIMUM = 'maximum';
- const FETCH_AFTER = 'after';
- const TEMP_ID_PREFIX = '~tmp';
- const TEMP_ID_DELIMITER = ".";
-
- /**
- * Get the id of this spec entry.
- *
- * @return null|string the id of this entry.
- */
- public function getId()
- {
- if (array_key_exists(static::ID_FIELD, $this->values)) {
- return $this->values[static::ID_FIELD];
- } else {
- return null;
- }
- }
-
- /**
- * Set the id of this spec entry. Id must be in a valid format or null.
- *
- * @param null|string $id the id of this entry - pass null to clear.
- * @return PluralAbstract provides a fluent interface
- * @throws \InvalidArgumentException if id does not pass validation.
- */
- public function setId($id)
- {
- if ($id !== null && !static::isValidId($id)) {
- throw new \InvalidArgumentException("Cannot set id. Id is invalid.");
- }
-
- // if populate was deferred, caller expects it
- // to have been populated already.
- $this->populate();
-
- $this->values[static::ID_FIELD] = $id;
-
- return $this;
- }
-
- /**
- * Determine if a spec record with the given id exists.
- * Must be implemented by sub-classes because this test
- * is impractical to generalize.
- *
- * @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 record.
- */
- abstract public static function exists($id, ConnectionInterface $connection = null);
-
- /**
- * Get the requested spec entry from Perforce.
- *
- * @param string $id the id of the entry to fetch.
- * @param ConnectionInterface $connection optional - a specific connection to use.
- * @return PluralAbstract instace of the requested entry.
- * @throws \InvalidArgumentException if no id is given.
- */
- public static function fetch($id, ConnectionInterface $connection = null)
- {
- // ensure a valid id is provided.
- if (!static::isValidId($id)) {
- throw new \InvalidArgumentException("Must supply a valid id to fetch.");
- }
-
- // if no connection given, use default.
- $connection = $connection ?: static::getDefaultConnection();
-
- // ensure id exists.
- if (!static::exists($id, $connection)) {
- throw new NotFoundException(
- "Cannot fetch " . static::SPEC_TYPE . " $id. Record does not exist."
- );
- }
-
- // construct spec instance.
- $spec = new static($connection);
- $spec->setId($id)
- ->deferPopulate();
-
- return $spec;
- }
-
- /**
- * Get all entries of this type from Perforce.
- *
- * @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.
- * FETCH_AFTER - set to an id _after_ which to start collecting entries
- * note: entries seen before 'after' count towards max.
- *
- * @param ConnectionInterface $connection optional - a specific connection to use.
- * @return FieldedIterator all records of this type.
- * @todo make limit work for depot (in a P4\Spec\Depot sub-class)
- */
- public static function fetchAll($options = array(), ConnectionInterface $connection = null)
- {
- // if no connection given, use default.
- $connection = $connection ?: static::getDefaultConnection();
-
- // get command to use
- $command = static::getFetchAllCommand();
-
- // get command flags for given fetch options.
- $flags = static::getFetchAllFlags($options);
-
- // fetch all specs.
- // configure a handler to enforce 'after' (skip entries up to and including 'after')
- $after = isset($options[static::FETCH_AFTER]) ? $options[static::FETCH_AFTER] : null;
- if (strlen($after)) {
- $idField = static::ID_FIELD;
- $isAfter = false;
- $handler = new Limit;
- $handler->setFilterCallback(
- function ($data) use ($after, $idField, &$isAfter) {
- if ($after && !$isAfter) {
- // id field could be upper or lower case in list output.
- $id = isset($data[lcfirst($idField)]) ? $data[lcfirst($idField)] : null;
- $id = !$id && isset($data[$idField]) ? $data[$idField] : $id;
- $isAfter = ($after == $id);
- return false;
- }
- return true;
- }
- );
- $result = $connection->runHandler($handler, $command, $flags);
- } else {
- $result = $connection->run($command, $flags);
- }
-
- // expand any sequences present
- $result->expandSequences();
-
- // convert result data to spec objects.
- $specs = new FieldedIterator;
- foreach ($result->getData() as $data) {
- $spec = static::fromSpecListEntry($data, $flags, $connection);
- $specs[$spec->getId()] = $spec;
- }
-
- return $specs;
- }
-
- /**
- * Create a temporary entry.
- *
- * The passed values can, optionally, specify the id of the temp entry.
- * If no id is passed in values, one will be generated following the
- * conventions described in makeTempId().
- *
- * Temp entries are deleted when the connection is closed.
- *
- * @param array|null $values optional - values to set on temp entry,
- * can include ID
- * @param function|null $cleanupCallback optional - callback to use for cleanup.
- * signature is:
- * function($entry, $defaultCallback)
- * @param ConnectionInterface $connection optional - a specific connection to use.
- * @return PluralAbstract instace of the temp entry.
- */
- public static function makeTemp(
- array $values = null,
- $cleanupCallback = null,
- ConnectionInterface $connection = null
- ) {
- // normalize to array
- $values = $values ?: array();
-
- // generate an id if no value for our id field is present
- if (!isset($values[static::ID_FIELD])) {
- $values[static::ID_FIELD] = static::makeTempId();
- }
-
- // create the temporary instance.
- $temp = new static($connection);
- $temp->set($values)->save();
-
- // remove the temp entry when the connection terminates.
- $defaultCallback = static::getTempCleanupCallback();
- $temp->getConnection()->addDisconnectCallback(
- function ($connection) use ($temp, $cleanupCallback, $defaultCallback) {
- try {
- // use the passed callback if valid, fallback to the default callback
- if (is_callable($cleanupCallback)) {
- $cleanupCallback($temp, $defaultCallback);
- } else {
- $defaultCallback($temp);
- }
- } catch (\Exception $e) {
- P4\Log::logException("Failed to delete temporary entry.", $e);
- }
- }
- );
-
- return $temp;
- }
-
- /**
- * Generate a temporary id by combining the id prefix
- * with the current time, pid and a random uniqid():
- *
- * ~tmp.<unixtime>.<pid>.<uniqid>
- *
- * The leading tilde ('~') places the temporary id at the end of
- * the list. The unixtime ensures that the oldest ids will
- * appear first (among temp ids), while the pid and uniqid provide
- * reasonable assurance that no two ids will collide.
- *
- * @return string an id suitable for use with temporary specs.
- */
- public static function makeTempId()
- {
- return implode(
- static::TEMP_ID_DELIMITER,
- array(
- static::TEMP_ID_PREFIX,
- time(),
- getmypid(),
- uniqid("", true)
- )
- );
- }
-
- /**
- * Delete this spec entry.
- *
- * @param array $params optional - additional flags to pass to delete
- * (e.g. some specs support -f to force delete).
- * @return PluralAbstract provides a fluent interface
- * @throws Exception if no id has been set.
- */
- public function delete(array $params = null)
- {
- $id = $this->getId();
- if ($id === null) {
- throw new Exception("Cannot delete. No id has been set.");
- }
-
- // ensure id exists.
- $connection = $this->getConnection();
- if (!static::exists($id, $connection)) {
- throw new NotFoundException(
- "Cannot delete " . static::SPEC_TYPE . " $id. Record does not exist."
- );
- }
-
- $params = array_merge((array) $params, array("-d", $id));
- $result = $connection->run(static::SPEC_TYPE, $params);
-
- // should re-populate.
- $this->deferPopulate(true);
-
- return $this;
- }
-
- /**
- * Get a field's raw value.
- * Extend parent to use getId() for id field.
- *
- * @param string $field the name of the field to get the value of.
- * @return mixed the value of the field.
- * @throws Exception if the field does not exist.
- */
- public function getRawValue($field)
- {
- if ($field === static::ID_FIELD) {
- return $this->getId();
- }
-
- // call-through.
- return parent::getRawValue($field);
- }
-
- /**
- * Set a field's raw value.
- * Extend parent to use setId() for id field.
- *
- * @param string $field the name of the field to set the value of.
- * @param mixed $value the value to set in the field.
- * @return SingularAbstract provides a fluent interface
- * @throws Exception if the field does not exist.
- */
- public function setRawValue($field, $value)
- {
- if ($field === static::ID_FIELD) {
- return $this->setId($value);
- }
-
- // call-through.
- return parent::setRawValue($field, $value);
- }
-
- /**
- * Extended to preserve id when values are cleared.
- * Schedule populate to run when data is requested (lazy-load).
- *
- * @param bool $reset optionally clear instance values.
- */
- public function deferPopulate($reset = false)
- {
- if ($reset) {
- $id = $this->getId();
- }
-
- parent::deferPopulate($reset);
-
- if ($reset) {
- $this->setId($id);
- }
- }
-
- /**
- * Provide a callback function to be used during cleanup of
- * temp entries. The callback should expect a single parameter,
- * the entry being removed.
- *
- * @return callable A callback function with the signature function($entry)
- */
- protected static function getTempCleanupCallback()
- {
- return function ($entry) {
- // remove the temp entry we are responsible for
- $entry->delete();
- };
- }
-
- /**
- * Check if the given id is in a valid format for this spec type.
- *
- * @param string $id the id to check
- * @return bool true if id is valid, false otherwise
- */
- protected static function isValidId($id)
- {
- $validator = new Validate\SpecName;
- return $validator->isValid($id);
- }
-
- /**
- * Extend parent populate to exit early if id is null.
- */
- protected function populate()
- {
- // early exit if populate not needed.
- if (!$this->needsPopulate) {
- return;
- }
-
- // don't attempt populate if id null.
- if ($this->getId() === null) {
- return;
- }
-
- parent::populate();
- }
-
- /**
- * Get raw spec data direct from Perforce. No caching involved.
- * Extends parent to supply an id to the spec -o command.
- *
- * @return array $data the raw spec output from Perforce.
- */
- protected function getSpecData()
- {
- $result = $this->getConnection()->run(
- static::SPEC_TYPE,
- array("-o", $this->getId())
- );
- return $result->expandSequences()->getData(-1);
- }
-
- /**
- * Given a spec entry from spec list output (e.g. 'p4 jobs'), produce
- * an instance of this spec with field values set where possible.
- *
- * @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.
- * @return PluralAbstract a (partially) populated instance of this spec class.
- */
- protected static function fromSpecListEntry($listEntry, $flags, ConnectionInterface $connection)
- {
- // most spec list entries have leading lower-case field
- // names which is inconsistent with defined field names.
- // make all field names lead with an upper-case letter.
- $keys = array_map('ucfirst', array_keys($listEntry));
- $listEntry = array_combine($keys, $listEntry);
-
- // convert common timestamps to dates
- if (isset($listEntry['Time'])) {
- $listEntry['Date'] = static::timeToDate($listEntry['Time'], $connection);
- unset($listEntry['Time']);
- }
- if (isset($listEntry['Update'])) {
- $listEntry['Update'] = static::timeToDate($listEntry['Update'], $connection);
- unset($listEntry['Update']);
- }
- if (isset($listEntry['Access'])) {
- $listEntry['Access'] = static::timeToDate($listEntry['Access'], $connection);
- unset($listEntry['Access']);
- }
-
- // instantiate new spec object and set raw field values.
- $spec = new static($connection);
- $spec->setRawValues($listEntry)
- ->deferPopulate();
-
- return $spec;
- }
-
- /**
- * Convert the given unix timestamp into the server's typical date
- * format accounting for the server's current timezone.
- *
- * @param int|string $time the timestamp to convert
- * @param ConnectionInterface $connection the connection to use
- * @return string date in the typical server format
- */
- protected static function timeToDate($time, ConnectionInterface $connection)
- {
- $date = new \DateTime('@' . $time);
-
- // try and use the p4 info timezone, if that fails fall back to our local timezone
- try {
- $date->setTimeZone($connection->getTimeZone());
- } catch (\Exception $e) {
- // we tried and failed; just let it use php's default time zone
- // note when creating a DateTime from a unix timestamp the timezone will
- // be UTC, we need to explicitly set it to the default time zone.
- $date->setTimeZone(new \DateTimeZone(date_default_timezone_get()));
- }
-
- return $date->format('Y/m/d H:i:s');
- }
-
- /**
- * Inverse function to timeToDate(), it converts the given date in server's typical
- * format into a unix timestamp accounting for the server's current timezone.
- *
- * @param string $date date in typical server's format (Y/m/d H:i:s) to convert
- * @param ConnectionInterface $connection the connection to use
- * @return int|false date in unix timestamp or false if unable to convert
- */
- protected static function dateToTime($date, ConnectionInterface $connection)
- {
- // try and use the p4 info timezone, if that fails fall back to our local timezone
- $dateTimeZone = null;
- try {
- $dateTimeZone = $connection->getTimeZone();
- } catch (\Exception $e) {
- // we tried and failed; just let it use php's default time zone
- }
-
- $dateTime = $dateTimeZone
- ? \DateTime::createFromFormat('Y/m/d H:i:s', $date, $dateTimeZone)
- : \DateTime::createFromFormat('Y/m/d H:i:s', $date);
-
- return $dateTime ? (int) $dateTime->format('U') : false;
- }
-
- /**
- * Produce set of flags for the spec list command, given fetch all options array.
- *
- * @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)
- {
- $flags = array();
-
- if (isset($options[self::FETCH_MAXIMUM])) {
- $flags[] = "-m";
- $flags[] = (int) $options[self::FETCH_MAXIMUM];
- }
-
- return $flags;
- }
-
- /**
- * Get the fetch all command, generally a plural version of the spec type.
- *
- * @return string Perforce command to use for fetchAll
- */
- protected static function getFetchAllCommand()
- {
- // derive list command from spec type by adding 's'
- // this works for most of the known plural specs
- return static::SPEC_TYPE . "s";
- }
- }