<?php
/**
* Provides persistent storage of data models in Perforce.
* Each record corresponds to a file in Perforce. Each record
* may contain properties that will be stored as attributes
* on the corresponding file (if sub-classed, a single property
* may be selected for storage in the file).
*
* Records are schemaless. Records of the same kind are not
* obligated to have the same fields. However, the record class
* may be sub-classed to define fields.
*
* Each record has an id that uniquely identifies the record in
* the record storage path. The storage base path is provided
* by the record storage adapter and may be narrowed (if sub-
* classed) by specifying a storage sub-path.
*
* If no id is specified when saving a record a new UUID will
* be assigned. UUIDs are used instead of incrementing numbers
* because they avoid collisions when record files are moved or
* branched in the depot.
*
* A single record can be fetched by its id via the fetch()
* method. Multiple records can be fetched via fetchAll().
*
* Records can be saved via the save() method and deleted via
* delete(). Each save() and delete() constitutes a submit in
* Perforce and produces a new revision of the record.
*
* Field names must be valid as file attribute names.
* Additionally, field names must not begin with an underscore
* ('_'). Leading underscore is reserved for field metadata.
*
* @copyright 2011 Perforce Software. All rights reserved.
* @license Please see LICENSE.txt in top-level folder of this distribution.
* @version <release>/<patch>
*/
class P4Cms_Record extends P4Cms_Record_Connected
{
const ENCODING_METADATA_KEY = "_encoding";
const ENCODING_FORMAT_JSON = "json";
const SAVE_THROW_CONFLICT = "throw";
const FROM_FILE_IMPORT = 'import';
protected $_id = null;
protected $_p4File = null;
protected $_metadata = array();
protected $_needsPopulate = false;
protected $_needsFilePopulate = false;
protected static $_whereCache = array();
protected static $_hasValidFields = null;
/**
* All records should have an id field.
*/
protected static $_idField = 'id';
/**
* Optionally, bin2hex encode identifiers when converting
* to/from depot filespecs to permit non-standard characters.
*/
protected static $_encodeIds = false;
/**
* Specifies the array of fields that the current Record class wishes to use.
* The implementing class MUST set this property.
*/
protected static $_fields = array();
/**
* Specifies the sub-path to use for storage of records.
* This is used in combination with the records path (provided
* by the storage adapter) to construct the full storage path.
* The implementing class MUST set this property.
*/
protected static $_storageSubPath = null;
/**
* Specifies the name of the record field which will be
* persisted in the file used to store the records.
* If desired, the implementing class needs to set this property
* to match an entry defined in the $_fields array.
* If left null, all fields will persist as file attributes.
*/
protected static $_fileContentField = null;
/**
* Create a new record instance, using optional field values, in a chainable fashion.
*
* @param array $values associative array of keyed field values
* to load into the model.
* @param P4Cms_Record_Adapter $adapter optional - storage adapter to use.
*/
public static function create($values = null, P4Cms_Record_Adapter $adapter = null)
{
return new static($values, $adapter);
}
/**
* Determine if id is valid identifier for this record.
*
* @param string $id record identifier
* @return boolean true if valid, otherwise false
*/
public function isValidId($id)
{
$id = static::$_encodeIds ? static::_encodeId($id) : $id;
$validator = new P4Cms_Validate_RecordId;
return $validator->isValid($id);
}
/**
* Get the id of this record.
* Extended to always return a string or null.
*
* @return string|null the value of the id field.
*/
public function getId()
{
$id = parent::getId();
// cast non-null ids to strings.
return $id === null ? null : (string) $id;
}
/**
* Set the id of this record.
*
* @param string|int|null $id the identifier of this record.
* @return P4Cms_Record provides fluent interface.
*/
public function setId($id)
{
if ($id !== null && !$this->isValidId($id)) {
throw new InvalidArgumentException("Cannot set id. Given id is invalid.");
}
// if populate was deferred, caller expects it
// to have been populated already.
$this->_populate();
// if id has changed, clear associated p4 file.
if ($id !== $this->getId()) {
$this->_p4File = null;
}
return parent::setId($id);
}
/**
* Get all of the model field names.
* Extends parent to populate first and throw an exception
* if it encounters any invalid field names.
*
* @return array a list of field names for this model.
* @throws P4Cms_Record_Exception if any of the predefined field names are invalid.
*/
public function getFields()
{
// populate but skip getting the file contents at this point
$this->_populate(true);
// validate predefined fields on first access.
if (static::$_hasValidFields === null) {
static::$_hasValidFields = true;
$validator = new P4Cms_Validate_RecordField;
foreach ($this->getDefinedFields() as $field) {
if (!$validator->isValid($field)) {
static::$_hasValidFields = false;
}
}
}
// if fields are invalid, throw exception.
if (static::$_hasValidFields === false) {
throw new P4Cms_Record_Exception(
"Cannot get fields. Record has one or more fields with invalid names."
);
}
// let parent do its thing.
$fields = parent::getFields();
// ensure file content field is present if defined.
if (!empty(static::$_fileContentField) && !in_array(static::$_fileContentField, $fields)) {
$fields[] = static::$_fileContentField;
}
return $fields;
}
/**
* Set a particular field value.
* Extends parent to validate names of new fields.
*
* @param string $field the name of the field to set the value of.
* @param mixed $value the value to set in the field.
* @return P4Cms_Model provides a fluent interface
* @throws P4Cms_Model_Exception if the field does not exist.
* @throws P4Cms_Record_Exception if the field name is invalid.
*/
public function setValue($field, $value)
{
// if field is new, validate field name.
if (!$this->hasField($field)) {
$validator = new P4Cms_Validate_RecordField;
if (!$validator->isValid($field)) {
throw new P4Cms_Record_Exception(
"Cannot set value. Field '$field' is not a valid field name."
);
}
}
return parent::setValue($field, $value);
}
/**
* Set all of the model's values at once.
* Extends parent to support passing a form object.
*
* Accepting a form object permits special handling of certain form
* elements via the P4Cms_Record_EnhancedElementInterface. This interface
* requires a populateRecord() method which allows the element to make
* decisions and modify other aspects of the record object.
*
* @param Zend_Form|array|null $values form or array of values to set on record.
* @param bool $filter optional - if true, ignores values for unknown fields.
* @return P4Cms_Record provides a fluent interface
*/
public function setValues($values, $filter = false)
{
// let parent deal with non-form input.
if (!$values instanceof Zend_Form) {
return parent::setValues($values, $filter);
}
// set values from form input.
$form = $values;
$values = $form->getValues();
foreach ($values as $field => $value) {
// skip read-only fields.
if ($this->isReadOnlyField($field)) {
continue;
}
// skip filtered fields.
if ($filter && !$this->hasField($field)) {
continue;
}
// handle record-aware elements.
$element = $form->getElement($field);
if ($element instanceof P4Cms_Record_EnhancedElementInterface) {
$element->populateRecord($this);
} else {
$this->setValue($field, $value);
}
}
return $this;
}
/**
* Set a field value to the contents of the given file.
*
* @param string $field the field to set the value of.
* @param string $file the full path to the file to read from.
* @return P4Cms_Record provides fluent interface.
* @throws InvalidArgumentException if the given file does not exist.
*/
public function setValueFromFile($field, $file)
{
if (!is_file($file)) {
throw new InvalidArgumentException("Cannot set value from file. File does not exist.");
}
$this->setValue($field, file_get_contents($file));
return $this;
}
/**
* Check if a record with the given id exists.
*
* Query options may, optionally, be passed. Any paths/ids present
* in the options will ignored.
*
* @param string|int $id the id of the record to fetch.
* @param P4Cms_Record_Query|array|null $query optional - query options to augment result.
* @param P4Cms_Record_Adapter $adapter optional - storage adapter to use.
* @return bool true if the record exists, false otherwise.
*/
public static function exists(
$id,
$query = null,
P4Cms_Record_Adapter $adapter = null)
{
$query = static::_normalizeQuery($query);
// if no id given, return false.
if (!strlen($id)) {
return false;
}
// clobber any existing IDs with our own and clear any paths on query.
$query->setIds(array($id))->setPaths(array());
return static::count($query, $adapter) > 0;
}
/**
* Get a specific record by id.
* A revision specifier may be, optionally, included in the id field.
* Rev Specifiers will influence the data returned but will not be
* present in the id of the returned record.
*
* Query options may, optionally, be passed. Any paths/ids present
* in the options will ignored.
*
* @param string|int $id the id of the record to fetch.
* @param P4Cms_Record_Query|array|null $query optional - query options to augment result.
* @param P4Cms_Record_Adapter $adapter optional - storage adapter to use.
* @return P4Cms_Record the requested record.
* @throws P4Cms_Record_NotFoundException if the requested record can't be found.
*/
public static function fetch($id, $query = null, P4Cms_Record_Adapter $adapter = null)
{
$query = static::_normalizeQuery($query);
// clobber any existing IDs with our own and clear any paths on options.
$query->setIds(array($id))->setPaths(array());
$results = static::fetchAll($query, $adapter);
if (!count($results)) {
throw new P4Cms_Record_NotFoundException(
"Cannot fetch record '$id'. Record does not exist."
);
}
return $results->first();
}
/**
* Get all records under the record storage path.
* Results can be limited by providing a query object or array.
*
* @param P4Cms_Record_Query|array|null $query optional - query options to augment result.
* @param P4Cms_Record_Adapter $adapter optional - storage adapter to use.
* @return P4Cms_Model_Iterator all records of this type.
*/
public static function fetchAll($query = null, P4Cms_Record_Adapter $adapter = null)
{
$query = static::_normalizeQuery($query);
// if no adapter given, use default.
$adapter = $adapter ?: static::getDefaultAdapter();
// convert record query to a p4 file query.
$query = $query->toFileQuery(get_called_class(), $adapter);
// early exit if no filespecs in query, return empty iterator.
if (is_array($query->getFilespecs()) && !count($query->getFilespecs())) {
return new P4Cms_Model_Iterator;
}
// fetch files from perforce.
$files = P4_File::fetchAll($query, $adapter->getConnection());
// convert files to records.
$records = new P4Cms_Model_Iterator;
foreach ($files as $file) {
$record = static::fromP4File($file, null, $adapter);
$records[$record->getId()] = $record;
}
return $records;
}
/**
* Count all records matching the given query.
*
* @param P4Cms_Record_Query|array|null $query optional - query options to augment result.
* @param P4Cms_Record_Adapter $adapter optional - storage adapter to use.
* @return integer The count of all matching records
*/
public static function count(
P4Cms_Record_Query $query = null,
P4Cms_Record_Adapter $adapter = null)
{
$query = static::_normalizeQuery($query);
// if no adapter given, use default.
$adapter = $adapter ?: static::getDefaultAdapter();
// convert record query to a p4 file query.
$query = $query->toFileQuery(get_called_class(), $adapter);
// early exit if no filespecs in query, return zero.
if (is_array($query->getFilespecs()) && !count($query->getFilespecs())) {
return 0;
}
// only fetch a single field - use headRev because it's tiny.
$query->setLimitFields('headRev');
// fetch count from perforce.
return P4_File::count($query, $adapter->getConnection());
}
/**
* Save this record. If the record does not have an id, a new
* UUID will be assigned to identify the record.
*
* @param string $description optional - a description of the change.
* @param null|string|array $options optional - passing the SAVE_THROW_CONFLICTS
* flag will cause exceptions on conflict; default
* behaviour is to crush any conflicts.
* Note this flag has no effect in batches.
* @return P4Cms_Record provides a fluent interface
*/
public function save($description = null, $options = null)
{
// if we are in a batch, pend the record to the
// changelist identified by the batch id.
$adapter = $this->getAdapter();
$change = ($adapter->inBatch()) ? $adapter->getBatchId() : null;
// if this record has an id, attempt to flush and edit the file.
// if it has no id, generate a new UUID to identify the record.
if ($this->getId()) {
$file = $this->_getP4File();
try {
// if our file isn't deleted simply attempt to sync and edit.
// perforce doesn't let you edit a deleted revision (you can't
// 'have' a deleted revision) so if it is deleted, sync to the
// previous revision and attempt to edit that.
if (!$file->isDeleted()) {
$file->sync();
$file->edit($change);
} else {
// if we are deleted, sync to the previous revision
// we create a new file object because we want to sync/edit
// the previous version without changing this record object's
// file instance (which would have negative side-effects).
$revSpec = '#' . ((int)$file->getStatus('headRev') - 1);
$previousFile = P4_File::fetch(
$file->getFilespec(true) . $revSpec,
$file->getConnection()
);
$previousFile->sync();
// attempt to open for edit. if the file is deleted at the head
// revision this will fail and we will open for add later.
$previousFile->edit($change);
// clear file's status cache so it can be aware of changes made
// by previousFile (e.g. 'isOpened' check will be acurate)
$file->clearStatusCache();
}
} catch (P4_File_Exception $e) {
// edit failed, but that's ok - we'll attempt to add below.
} catch (P4_Connection_CommandException $e) {
// if command failed due to a chmod error, just eat the exception;
// file will get created later. normally this problem should not
// occur, but if a virtual integrate or copy was performed, it can.
if (!stripos($e->getMessage(), "Command failed: chmod: ") === 0) {
throw $e;
}
}
} else {
$this->setId((string) new P4Cms_Uuid);
$file = $this->_getP4File();
}
// write file content field to file contents.
// if we don't have a file content field we
// simply touch the file to ensure it's on disk
if (static::hasFileContentField()) {
$field = static::getFileContentField();
// we avoid reading the file into memory if possible
// but there are situations where we have to:
// - if this is an add write the value to persist the default
// - if this is an edit the file should already exist but if its missing
// make a go of reading its current value and writing it back out
// - lastly if we have a value in memory we need to write it to persist it
if (!$file->isOpened()
|| !file_exists($file->getLocalFilename())
|| array_key_exists($field, $this->_values)
) {
$value = $this->_encodeFieldValue($field, $this->_getValue($field));
$file->setLocalContents($value);
}
} else {
$file->touchLocalFile();
}
// if file is not yet opened, add it now - we do this after
// the file is written so perforce can detect the file type.
if (!$file->isOpened()) {
$file->add($change);
}
// write field values and metadata as file attributes.
// we clear any attributes we don't know about (ie. the field was
// explicitly unset, or this record was not fetched, but happens
// to collide with a file in perforce that has attributes)
$clear = array();
$ignore = array();
$attributes = array();
// collect field values to set as attributes.
foreach ($this->getFields() as $field) {
if ($field != static::$_idField && $field != static::$_fileContentField) {
$attributes[$field] = $this->_encodeFieldValue($field, $this->_getValue($field));
}
}
// collect metadata to set or ignore -- if we were unable to decode
// certain metadata when reading it, we set it to false to indicate it
// should be left alone (could be third-party data for example)
foreach ($this->_metadata as $field => $data) {
$field = "_" . $field;
if (!empty($data)) {
$attributes[$field] = $this->_encodeMetadata($data);
} else if ($data === false) {
$ignore[] = $field;
}
}
// determine the fields to clear - as above, we clear any attributes
// we don't know about so long as they aren't listed as ignored.
foreach ($file->getAttributes() as $key => $value) {
if (!array_key_exists($key, $attributes) && !in_array($key, $ignore)) {
$clear[] = $key;
}
}
$file->setAttributes($attributes);
$file->clearAttributes($clear);
// if we're not in a batch, submit file to perforce
if (!$adapter->inBatch()) {
if (!$description) {
$description = $this->_generateSubmitDescription();
}
// default option is to 'accept yours' but we switch to
// null if SAVE_THROW_CONFLICTS flag is passed.
$resolveFlag = P4_File::RESOLVE_ACCEPT_YOURS;
if (in_array(static::SAVE_THROW_CONFLICT, (array)$options)) {
$resolveFlag = null;
}
$file->submit($description, $resolveFlag);
}
return $this;
}
/**
* Store a record. Equivalent to the instance method save(), but offered
* as a static method for convenience.
*
* @param array|string|null $values optional - list of values for the new record
* if a string is given, it will be taken as the
* record identifier - if no id given, a new
* UUID will be assigned.
* @param P4Cms_Record_Adapter $adapter optional - storage adapter to use.
* @return P4Cms_Record provides a fluent interface.
*/
public static function store($values = array(), P4Cms_Record_Adapter $adapter = null)
{
// normalize values to an array.
if (!is_array($values)) {
$values = array(static::$_idField => $values);
}
$record = static::create($values, $adapter);
$record->save();
return $record;
}
/**
* Delete this record.
*
* @param string $description optional - a description of the change.
* @return P4Cms_Record provides fluent interface.
*/
public function delete($description = null)
{
// if we are in a batch, pend the record to the
// changelist identified by the batch id.
$adapter = $this->getAdapter();
$change = ($adapter->inBatch()) ? $adapter->getBatchId() : null;
// open depot file for delete.
$file = $this->_getP4File();
try {
$file->delete($change);
} catch (P4_File_Exception $e) {
// ignore exception if file was open for add - otherwise rethrow.
if (!$file->isOpened() || $file->getStatus('action') !== 'add') {
throw $e;
}
}
// ensure local file deleted.
if (file_exists($file->getLocalFilename())) {
$file->deleteLocalFile();
}
// if we're not in a batch, submit file to perforce
if (!$adapter->inBatch()) {
if (!$description) {
$description = "Deleted '" . static::$_storageSubPath . "' record.";
}
$file->submit($description);
}
return $this;
}
/**
* Remove a record from storage. Equivalent to delete but class-based for convenience.
*
* @param string $id the id of the record to remove.
* @param P4Cms_Record_Adapter $adapter optional - storage adapter to use.
* @return P4Cms_Record provides fluent interface.
*/
public static function remove($id, P4Cms_Record_Adapter $adapter = null)
{
// if no adapter given, use default.
$adapter = $adapter ?: static::getDefaultAdapter();
$record = new static;
$record->setId($id)
->setAdapter($adapter)
->delete();
return $record;
}
/**
* Override parent to clear associated p4 file if adapter has changed to ensure the file
* will get the connection from the new adapter.
*
* @param P4Cms_Record_Adapter $adapter the adapter to use for this instance.
* @return P4Cms_Record provides fluent interface.
*/
public function setAdapter(P4Cms_Record_Adapter $adapter)
{
// if adapter has changed, clear associated p4 file.
if ($adapter !== $this->_adapter) {
$this->_p4File = null;
}
return parent::setAdapter($adapter);
}
/**
* Get the Perforce path used for the storage of this class of records.
* The storage path is a combination of the records path (provided by the record
* storage adapter) and the sub-path (defined by the record class).
*
* @param P4Cms_Record_Adapter $adapter optional - storage adapter to use.
* @return string the path used to store this class of records.
*/
public static function getStoragePath(P4Cms_Record_Adapter $adapter = null)
{
// if no adapter given, use default.
$adapter = $adapter ?: static::getDefaultAdapter();
// normalize the path components.
$basePath = rtrim($adapter->getBasePath(), '/');
$subPath = rtrim(static::$_storageSubPath, '/');
// return basePath w. subPath (if set).
return strlen($subPath) ? $basePath . '/' . $subPath : $basePath;
}
/**
* Determine if this record class has a field mapped to the file contents.
*
* @return bool true if the class has a file content field; false otherwise.
*/
public static function hasFileContentField()
{
return isset(static::$_fileContentField);
}
/**
* Get the name of the field that is mapped to the file contents.
*
* @return string the name of the file content field.
* @throws P4Cms_Record_Exception if there is no file content field.
*/
public static function getFileContentField()
{
if (!static::hasFileContentField()) {
throw new P4Cms_Record_Exception(
"Cannot get the file content field. No field is mapped to the file."
);
}
return static::$_fileContentField;
}
/**
* Get metadata for the given field.
*
* Field metadata is stored in a file attribute named for the
* field, but with a leading underscore (e.g. '_field-name').
*
* @param string $field the field to get metadata for.
* @return array the metadata for the given field.
* @throws P4Cms_Record_Exception if the field does not exist.
*/
public function getFieldMetadata($field)
{
// populate but skip getting the file contents at this point
$this->_populate(true);
if (!$this->hasField($field)) {
throw new P4Cms_Record_Exception(
"Cannot get field metadata for a non-existant field."
);
}
return $this->_getFieldMetadata($field);
}
/**
* Set metadata for the given field.
*
* Field metadata is stored in a file attribute named for the
* field, but with a leading underscore (e.g. '_field-name').
*
* @param string $field the field to set metadata for.
* @param array|null $data the metadata to store for the field.
* @return P4Cms_Record provides fluent interface.
* @throws P4Cms_Record_Exception if the field does not exist.
*/
public function setFieldMetadata($field, array $data = null)
{
if (!$this->hasField($field)) {
throw new P4Cms_Record_Exception(
"Cannot set field metadata for a non-existant field."
);
}
$this->_metadata[$field] = $data;
return $this;
}
/**
* Test if this record is deleted in perforce.
*
* @return boolean true if record is deleted or doesn't have an id,
* otherwise returns true.
*/
public function isDeleted()
{
return $this->getId() && $this->_getP4File()->isDeleted();
}
/**
* Provides access to a copy of the p4_file object which is underlying the
* current record instance. By default it returns a cloned copy, pass true
* to get a reference.
*
* @param bool $reference optional - pass true to get a reference to the file
* @return P4_File the file associated with this record instance.
*/
public function toP4File($reference = false)
{
return $reference
? $this->_getP4File()
: clone $this->_getP4File();
}
/**
* Given a p4 file instance, produce a record instance with id
* adapter and associated p4 file object all set appropriately.
*
* Under normal operation a reference is maintained to the given
* file and the id of the record is derived from the filespec.
* If the import option is specified, only the values are taken
* from the file. No reference is maintained and the resulting
* record will have a null id.
*
* @param P4_File $file a p4 file instance to convert into a record.
* @param string|array|null $options options to influence the operation:
* FROM_FILE_IMPORT - only the file's values are
* used, the id is ignored
* @param P4Cms_Record_Adapter $adapter optional - storage adapter to use.
* @return P4Cms_Record the record instance generated from the file.
*/
public static function fromP4File($file, $options = null, P4Cms_Record_Adapter $adapter = null)
{
// if no adapter given, use default.
$import = in_array(static::FROM_FILE_IMPORT, (array)$options);
$adapter = $adapter ?: static::getDefaultAdapter();
$id = $import ? null : static::depotFileToId($file->getDepotFilename(), $adapter);
$record = new static();
$record->setId($id)
->setAdapter($adapter)
->_setP4File($file)
->_deferPopulate();
// if we are doing an import force the record to read in
// values then clear any reference to the passed file.
if ($import) {
$record->_populate()
->_setP4File(null);
}
return $record;
}
/**
* Get the depot-syntax form of the perforce path used for the storage of
* this class of records. The path returned by getStoragePath() is in an
* unknown form. It could be in depot, client or local file-system syntax.
*
* @param P4Cms_Record_Adapter $adapter optional - storage adapter to use.
* @return string the depot path used to store this class of records.
*/
public static function getDepotStoragePath(P4Cms_Record_Adapter $adapter = null)
{
// if no adapter given, use default.
$adapter = $adapter ?: static::getDefaultAdapter();
// get the storage path (in unknown form).
$storagePath = static::getStoragePath($adapter);
// we cache the depot-syntax version on a per-path, per-adapter basis.
// to avoid running 'p4 where' everytime we need to get the depot storage path.
if (isset(static::$_whereCache[spl_object_hash($adapter)][$storagePath])) {
return static::$_whereCache[spl_object_hash($adapter)][$storagePath];
}
// convert to depot-syntax.
$result = $adapter->getConnection()->run('where', $storagePath . '/...');
if ($result->hasWarnings()) {
throw new P4Cms_Record_Exception(
"Cannot get the depot storage path. Storage path is not in client view."
);
}
$depotPath = substr($result->getData(0, 'depotFile'), 0, -4);
// cache per adapter/path.
static::$_whereCache[spl_object_hash($adapter)][$storagePath] = $depotPath;
return $depotPath;
}
/**
* Given a record id, determine the corresponding filespec.
*
* @param string $id the record id to get the filespec for.
* @param P4Cms_Record_Adapter $adapter optional - storage adapter to use.
* @return string the filespec for a given record id.
*/
public static function idToFilespec($id, P4Cms_Record_Adapter $adapter = null)
{
// if no adapter given, use default.
$adapter = $adapter ?: static::getDefaultAdapter();
// id is required.
if (!strlen($id)) {
throw new InvalidArgumentException("Cannot get filespec for an empty id.");
}
// optionally encode id for storage.
if (static::$_encodeIds) {
$id = static::_encodeId($id);
}
return static::getStoragePath($adapter) . '/' . $id;
}
/**
* Return name of the id field.
*
* @return string name of id field.
*/
public static function getIdField()
{
return static::$_idField;
}
/**
* Given a record filespec in depotFile syntax, determine the id.
*
* @param string $depotFile a record depotFile.
* @param P4Cms_Record_Adapter $adapter optional - storage adapter to use.
* @return string|int the id portion of the depotFile file spec.
*/
public static function depotFileToId(
$depotFile,
P4Cms_Record_Adapter $adapter = null)
{
// if no adapter given, use default.
$adapter = $adapter ?: static::getDefaultAdapter();
// strip the depot storage path from the depotFile to produce the id.
$depotBasePath = static::getDepotStoragePath($adapter) . '/';
if (strpos($depotFile, $depotBasePath) === 0) {
$id = substr($depotFile, strlen($depotBasePath));
// optionally decode stored id.
if (static::$_encodeIds) {
$id = static::_decodeId($id);
}
} else {
throw new P4Cms_Record_Exception(
"Cannot determine record id for a file outside of the record storage path."
);
}
return $id;
}
/**
* Set the corresponding P4 File object instance.
* Used when fetching records to prime the record object.
*
* @param P4_File|null $file the corresponding P4_File object.
* @return P4Cms_Record provides fluent interface.
* @throws Record_Exception if the file is not a valid P4_File object.
*/
protected function _setP4File($file)
{
if (!$file instanceof P4_File && !is_null($file)) {
throw new P4Cms_Record_Exception(
'Cannot set P4 File. The given file is not a valid P4_File object.'
);
}
$this->_p4File = $file;
return $this;
}
/**
* Get the P4 File object that corresponds to this record.
*
* @return P4_File corresponding P4 File instance.
*/
protected function _getP4File()
{
// create corresponding p4 file instance if necessary.
if (!$this->_p4File instanceof P4_File) {
$filespec = static::idToFilespec($this->getId(), $this->getAdapter());
$this->_p4File = new P4_File;
$this->_p4File->setFilespec($filespec)
->setConnection($this->getAdapter()->getConnection());
}
return $this->_p4File;
}
/**
* Encode metadata for storage (using JSON).
*
* @param mixed $data The data to be encoded.
* @return string The 'encoded' data.
*/
protected function _encodeMetadata($data)
{
return Zend_Json::encode($data);
}
/**
* Decode metadata (presumably from storage).
*
* @param string $data The data to be decoded.
* @return mixed The 'decoded' data.
*/
protected function _decodeMetadata($data)
{
return strlen($data)
? Zend_Json::decode($data)
: null;
}
/**
* Encode field's value as JSON if its not string or numeric.
* Updates field metadata to record encoding.
*
* @param string $field the field to encode the value for.
* @param mixed $value the value to encode.
* @return string the encoded value.
*/
protected function _encodeFieldValue($field, $value)
{
$metadata = $this->_getFieldMetadata($field);
if (is_numeric($value)) {
$value = (string) $value;
}
if (isset($value) && !is_string($value)) {
// json encode
$value = Zend_Json::encode($value);
$metadata[self::ENCODING_METADATA_KEY] = self::ENCODING_FORMAT_JSON;
} else if (array_key_exists(self::ENCODING_METADATA_KEY, $metadata)) {
unset($metadata[self::ENCODING_METADATA_KEY]);
}
$this->setFieldMetadata($field, $metadata);
return $value;
}
/**
* Decode field's value if it is encoded (checks field metadata).
*
* @param string $field the field we are decoding
* @param string $value the encoded value.
* @return mixed the decoded value (could be string or array).
*/
protected function _decodeFieldValue($field, $value)
{
$metadata = $this->_getFieldMetadata($field);
if (strlen($value)
&& isset($metadata[self::ENCODING_METADATA_KEY])
&& self::ENCODING_FORMAT_JSON === $metadata[self::ENCODING_METADATA_KEY]
) {
try {
return Zend_Json::decode($value);
} catch (Exception $e) {
P4Cms_Log::logException("Failed to decode field value", $e);
}
}
// convert empty strings to null.
// this is done so that null values round-trip correctly
// this prevents empty strings from round-tripping, but
// that was deemed a reasonable trade-off.
if (!strlen($value)) {
return null;
}
return $value;
}
/**
* Overrides parent to populate the record first.
* Get a raw (but decoded) field value. Does not use custom accessor methods.
* If idField is specified; will utilize 'getId' function.
*
* @param string $field the name of the field to get the value of.
* @return mixed the value of the field.
* @throws P4Cms_Model_Exception if the field does not exist.
*/
protected function _getValue($field)
{
$excludeFile = ($field !== static::$_fileContentField);
$this->_populate($excludeFile);
return parent::_getValue($field);
}
/**
* Schedule populate to run when data is requested (lazy-load).
*
* @return P4Cms_Record provides fluent interface.
*/
protected function _deferPopulate()
{
$this->_needsPopulate = true;
$this->_needsFilePopulate = true;
return $this;
}
/**
* Get the values for this record from Perforce and set them
* in the instance. Won't clobber existing values.
*
* @param bool $excludeFile optional - skip populating file content
* @return P4Cms_Record provides fluent interface.
*/
protected function _populate($excludeFile = false)
{
// if record has no id and no file, we can't pull from storage.
if (!$this->hasId() && !$this->_p4File) {
return $this;
}
if ($this->_needsPopulate) {
// clear needsPopulate flag.
$this->_needsPopulate = false;
// get file attributes from associated p4 file.
$file = $this->_getP4File();
try {
$attributes = $file->getAttributes();
} catch (P4_File_Exception $e) {
// no matching file in storage, nothing to populate from.
return $this;
}
// set field metadata first from file attributes.
foreach ($attributes as $key => $value) {
if ($key[0] === '_') {
$field = substr($key, 1);
if (!array_key_exists($field, $this->_metadata)) {
try {
$this->_metadata[$field] = $this->_decodeMetadata($value);
} catch (Exception $e) {
// we failed to decode the metadata entry -- we set it to
// false to tell save that the attribute should be ignored.
$this->_metadata[$field] = false;
}
}
}
}
// set field values from file attributes.
$validator = new P4Cms_Validate_RecordField;
foreach ($attributes as $key => $value) {
if ($validator->isValid($key)) {
if (!array_key_exists($key, $this->_values)) {
$this->_values[$key] = $this->_decodeFieldValue($key, $value);
}
}
}
}
if ($this->_needsFilePopulate && !$excludeFile) {
// clear needsPopulate flag.
$this->_needsFilePopulate = false;
// set file content field if record has one.
$fileField = static::$_fileContentField;
if (strlen($fileField) && !array_key_exists($fileField, $this->_values)) {
$file = $this->_getP4File();
try {
if (!$file->isDeleted()) {
$contents = $file->getDepotContents();
} else {
// if we are deleted, pull the file content from the previous revision
$revSpec = '#' . ((int)$file->getStatus('headRev') - 1);
$contents = P4_File::fetch(
$file->getFilespec(true) . $revSpec,
$file->getConnection()
)->getDepotContents();
}
$this->_values[$fileField] = $this->_decodeFieldValue($fileField, $contents);
} catch (P4_File_Exception $e) {
// presumably no depot file content to get.
}
}
}
return $this;
}
/**
* Get metadata for the given field - doesn't populate or check field existance.
*
* Field metadata is stored in a file attribute named for the
* field, but with a leading underscore (e.g. '_field-name').
*
* @param string $field the field to get metadata for.
* @return array the metadata for the given field.
*/
protected function _getFieldMetadata($field)
{
if (array_key_exists($field, $this->_metadata)) {
return (array) $this->_metadata[$field];
} else {
return array();
}
}
/**
* Encode id for storage (via bin2hex).
*
* @param string $id the id to encode.
* @return string the encoded id.
*/
protected function _encodeId($id)
{
return bin2hex($id);
}
/**
* Decode stored id (reverse bin2hex).
*
* @param string $id the id to decode.
* @return string the decoded id.
*/
protected function _decodeId($id)
{
return pack("H*", $id);
}
/**
* Generate a save description for this record.
*
* @return string a default submit description.
*/
protected function _generateSubmitDescription()
{
return static::$_storageSubPath
? "Saved '" . static::$_storageSubPath . "' record."
: "Saved record.";
}
/**
* Queries arguments (e.g. to fetch/fetchAll) can be given as a
* query object, an array or null. This helper method normalizes
* the input to a query object and throws on invalid arguments.
*
* @param P4Cms_Record_Query|array|null $query optional - query options to augment result. *
* @return P4Cms_Record_Query the query input normalized to a query object.
* @throws InvalidArgumentException if the query input is not valid.
*/
protected static function _normalizeQuery($query)
{
if (!$query instanceof P4Cms_Record_Query && !is_array($query) && !is_null($query)) {
throw new InvalidArgumentException(
'Query must be a P4Cms_Record_Query, array or null'
);
}
// normalize array input to a query
if (is_array($query)) {
$query = new P4Cms_Record_Query($query);
}
// if null query given, make a new one.
$query = $query ?: new P4Cms_Record_Query;
return $query;
}
}