Record.php #1

  • //
  • guest/
  • perforce_software/
  • chronicle/
  • main/
  • library/
  • P4Cms/
  • Record.php
  • View
  • Commits
  • Open Download .zip Download (45 KB)
<?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;
    }
}
# Change User Description Committed
#1 16170 perforce_software Move Chronicle files to follow new path scheme for branching.
//guest/perforce_software/chronicle/library/P4Cms/Record.php
#1 8972 Matt Attaway Initial add of the Chronicle source code