/ * @todo add support for limiting returned fields. */ class P4Cms_Record_Query { const QUERY_INCLUDE_DELETED = 'includeDeleted'; const QUERY_RECORD_CLASS = 'recordClass'; const QUERY_LIMIT_FIELDS = 'limitFields'; const QUERY_MAX_DEPTH = 'maxDepth'; const QUERY_MAX_ROWS = 'maxRows'; const QUERY_PATHS = 'paths'; const QUERY_IDS = 'ids'; const SORT_DATE = '#REdate'; const SORT_HEAD_REV = '#RErev'; const SORT_HAVE_REV = '#NEhrev'; const SORT_FILE_TYPE = '#NEtype'; const SORT_FILE_SIZE = '#REsize'; const SORT_ASCENDING = 'a'; const SORT_DESCENDING = 'd'; const RECORD_BASE_CLASS = 'P4Cms_Record'; protected $_options = null; protected $_query = null; protected $_recordClass = null; /** * Constructor that accepts an array of query options * * @param array $options Optional array of options to populate */ public function __construct($options = array()) { $this->reset(); // some options that are valid for both this and file query as well, // may not be directly passed to the file query constructor $fileQueryOptions = array_diff($options, array(static::QUERY_LIMIT_FIELDS)); $this->_query = new P4_File_Query($fileQueryOptions); if (isset($options) and is_array($options)) { foreach ($options as $key => $value) { if (array_key_exists($key, $this->_options)) { $method = 'set'. ucfirst($key); $this->$method($value); } } } } /** * Creates and returns a new Query class. Useful for working * around PHP's lack of new chaining. * * @param array $options Optional array of options to populate for new Query class * @return P4Cms_Record_Query */ public static function create($options = array()) { return new static($options); } /** * Reset the current query object to its default state. * * @return P4Cms_Record_Query provide a fluent interface. */ public function reset() { $this->_options = array( static::QUERY_INCLUDE_DELETED => false, static::QUERY_RECORD_CLASS => null, static::QUERY_LIMIT_FIELDS => null, static::QUERY_MAX_DEPTH => null, static::QUERY_MAX_ROWS => null, static::QUERY_PATHS => null, static::QUERY_IDS => null ); $this->_query = new P4_File_Query; return $this; } /** * Provide all of the current options as an array. * * @return array The current query options as an array. */ public function toArray() { $array = $this->_query->toArray(); // hide filespecs option (superseded by paths) unset($array[P4_File_Query::QUERY_FILESPECS]); // hide max-files option (superseded by max-rows) unset($array[P4_File_Query::QUERY_MAX_FILES]); return array_merge($array, $this->_options); } /** * Retrieve the current filter expression. * Null means no filtering will take place. * * @return P4Cms_Record_Filter|null The current filter object or null. */ public function getFilter() { return $this->_query->getFilter(); } /** * Add a filter to this query. * * @param P4Cms_Record_Filter $filter the filter to add. * @return P4Cms_Record_Query provides fluent interface. */ public function addFilter(P4Cms_Record_Filter $filter) { $currentFilter = $this->getFilter(); if (!$currentFilter) { return $this->setFilter($filter); } $currentFilter->addSubFilter($filter); return $this; } /** * Set the "filter expression" to limit the returned set of records. * See 'p4 help fstat' and 'p4 help jobview' for more information on * the filter format. Accepts a P4Cms_Record_Filter or string for input, * or null to remove any filter. * * @param string|array|P4Cms_Record_Filter|null $filter The desired filter expression. * @return P4Cms_Record_Query provide a fluent interface. */ public function setFilter($filter = null) { if (is_string($filter) || is_array($filter)) { $filter = new P4Cms_Record_Filter($filter); } if (!$filter instanceof P4Cms_Record_Filter && !is_null($filter)) { throw new InvalidArgumentException( 'Cannot set filter; argument must be a P4Cms_Record_Filter, an array, a string, or null.' ); } $this->_query->setFilter($filter); return $this; } /** * Get the current sort field. * Null means default sorting will take place. * * @return string The current sort field, or null if not set. */ public function getSortBy() { return $this->_query->getSortBy(); } /** * Set the record field which will be used to sort results. Valid sort fields are: * SORT_DATE, SORT_HEAD_REV, SORT_HAVE_REV, SORT_FILE_TYPE, SORT_FILE_SIZE. * Specify null to receive records in the default order. * * @param array|string|null $sortBy An array of fields or field => options, a string field, * or default null. * @param array|null $options Sorting options, only used when sortBy is a string. * @return P4Cms_Record_Query provide a fluent interface. * @see P4_File_Query */ public function setSortBy($sortBy = null, $options = null) { $this->_query->setSortBy($sortBy, $options); return $this; } /** * Retrieve the current reverse order flag setting. * True means that the sort order will be reversed. * * @return boolean true if the sort order will be reversed. */ public function getReverseOrder() { return $this->_query->getReverseOrder(); } /** * Set the flag indicating whether the results will be returned in reverse order. * * @param boolean $reverse Set to true to reverse sort order. * @return P4Cms_Record_Query provide a fluent interface. */ public function setReverseOrder($reverse = false) { $this->_query->setReverseOrder($reverse); return $this; } /** * Retrieve the flag indicating whether deleted files will be included in results. * True means deleted files will be included. * * @return boolean True indicates deleted files included. */ public function getIncludeDeleted() { return $this->_options[static::QUERY_INCLUDE_DELETED]; } /** * Set the flag indicating whether deleted files will be included in results. * True means deleted files should be included. * * @param boolean $include Flag to include deleted files. * @return P4Cms_Record_Query provide a fluent interface. */ public function setIncludeDeleted($include = false) { $this->_options[static::QUERY_INCLUDE_DELETED] = (bool) $include; return $this; } /** * Return the starting row for matching records. * Null means all matching records will be returned. * * @return int|null The starting row. */ public function getStartRow() { return $this->_query->getStartRow(); } /** * Set the starting row to return from matching records, * or null to return all matching records. * * @param int|null $row The starting row. * @return P4Cms_Record_Query provide a fluent interface. */ public function setStartRow($row = null) { $this->_query->setStartRow($row); return $this; } /** * Retrieve the maximum number of records to include in results. * 0 or null means unlimited. * * @return integer The maximum number of records to include in results. */ public function getMaxRows() { return $this->_query->getMaxFiles(); } /** * Set to limit the number of matching records returned, or null * to return all matching records. * * @param int|null $max The maximum number of records to return. * @return P4_File_Query provide a fluent interface. */ public function setMaxRows($max = null) { $this->_options[static::QUERY_MAX_ROWS] = $max; $this->_query->setMaxFiles($max); return $this; } /** * Retrieve the list of record fields to return in server responses. * Null means all fields will be returned. * * @return array|null The current list of record fields. */ public function getLimitFields() { return $this->_options[static::QUERY_LIMIT_FIELDS]; } /** * Set the list of fields to include in the response from the server. * * Record limitFields are converted to the file fields (i.e. file attributes) when * this query is converted to the file query as we need to know the record class to * determine id and file content fields that have to be skipped. * * @param string|array|null $fields The list of desired fields. Supply a string to specify * one field, or supply a null to retrieve all fields. * @return P4Cms_Record_Query provide a fluent interface. */ public function setLimitFields($fields = array()) { $this->_options[static::QUERY_LIMIT_FIELDS] = $fields; return $this; } /** * Retrieve the maximum path depth of records to include in results. * 0 means include records only at the current depth. Null means * unlimited. * * @return integer The maximum depth of records to include in results. */ public function getMaxDepth() { return $this->_options[static::QUERY_MAX_DEPTH]; } /** * Set the maximum path depth for records to be included in results. * 0 means include records only at the current depth. Null means * unlimited. * * @param integer $depth The maximum depth of records to include in results. * @return P4Cms_Record_Query provide a fluent interface. */ public function setMaxDepth($depth = null) { // accept numeric string values, for convenience. if (is_string($depth)) { $depth = (int) $depth; } if (isset($depth) and (!is_integer($depth) or $depth < 0)) { throw new InvalidArgumentException( 'Cannot set maximum depth; argument must be a non-negative integer or null.' ); } $this->_options[static::QUERY_MAX_DEPTH] = $depth; return $this; } /** * Retrieve the IDs to be used to fetch records. * * @return array|null The list of ids to be used to fetch records. */ public function getIds() { return $this->_options[static::QUERY_IDS]; } /** * Set the list of IDs to be used to fetch records. * * @param array|null $ids The IDs to be used to fetch records. * @return P4Cms_Record_Query provide a fluent interface. */ public function setIds($ids = null) { if (!is_array($ids) && !is_null($ids)) { throw new InvalidArgumentException("IDs should be an array or null"); } $this->_options[static::QUERY_IDS] = $ids; return $this; } /** * Add a single ID to the list of IDs to be used to fetch records. * * @param string|int $id An ID to be added. * @return P4Cms_Record_Query provide a fluent interface. */ public function addId($id) { if (!is_string($id) && !is_int($id)) { throw new InvalidArgumentException('Cannot add ID; argument must be a string or int.'); } return $this->addIds(array($id)); } /** * Add IDs to the list of IDs to be used to fetch records. * * @param array $ids set of string or int IDs to be added. * @return P4Cms_Record_Query provide a fluent interface. */ public function addIds($ids) { if (!is_array($ids)) { throw new InvalidArgumentException('Cannot add IDs; argument must be an array.'); } $this->setIds(array_merge($this->getIds() ?: array(), $ids)); return $this; } /** * Remove a single ID from the list to be used to fetch records. * * @param string|int $id ID to remove from list * @return P4Cms_Record_Query provide a fluent interface. */ public function removeId($id) { if (!is_string($id) && !is_int($id)) { throw new InvalidArgumentException('Cannot remove id; argument must be a string or int.'); } return $this->removeIds(array($id)); } /** * Remove IDs from the list to be used to fetch records. * * @param array $ids The IDs to be removed. * @return P4Cms_Record_Query provide a fluent interface. */ public function removeIds($ids = array()) { if (!is_array($ids)) { throw new InvalidArgumentException('Cannot remove IDs; argument must be an array.'); } $currentIds = $this->getIds() ?: array(); foreach ($ids as $id) { $index = array_search($id, $currentIds); if ($index !== false) { unset($currentIds[$index]); } } $this->setIds($currentIds); return $this; } /** * Retrieve the paths to be used to fetch records. * * @return array|null The list of paths to be used to fetch records. */ public function getPaths() { return $this->_query->getFilespecs(); } /** * Set the list of paths to be used to fetch records. * * @param array|null $paths The paths to be used to fetch records. * @return P4Cms_Record_Query provide a fluent interface. */ public function setPaths($paths = null) { $this->_query->setFilespecs($paths); $this->_options[static::QUERY_PATHS] = $paths; return $this; } /** * Add a single path to the list of paths to be used to fetch records. * * @param string $path A path to be added. * @param bool $intersect optional - defaults to false - intersect with existing paths. * @return P4Cms_Record_Query provide a fluent interface. */ public function addPath($path, $intersect = false) { if (!isset($path) or !is_string($path)) { throw new InvalidArgumentException('Cannot add path; argument must be a string.'); } return $this->addPaths(array($path), $intersect); } /** * Add paths to the list of paths to be used to fetch records. * * @param array $paths set of paths to be added. * @param bool $intersect optional - defaults to false - intersect with existing paths. * @return P4Cms_Record_Query provide a fluent interface. */ public function addPaths($paths = array(), $intersect = false) { if (!isset($paths) or !is_array($paths)) { throw new InvalidArgumentException('Cannot add paths; argument must be an array.'); } // if paths are currently null, no need to combine. if ($this->getPaths() === null) { $this->setPaths($paths); return $this; } // combine arrays appropriately if ($intersect) { $this->setPaths(array_intersect($this->getPaths(), $paths)); } else { $this->setPaths(array_merge($this->getPaths(), $paths)); } return $this; } /** * Remove a single path from the list to be used to fetch records. * * @param string $path path to remove from list * @return P4Cms_Record_Query provide a fluent interface. */ public function removePath($path) { if (!is_string($path)) { throw new InvalidArgumentException('Cannot remove path; argument must be a string.'); } return $this->removePaths(array($path)); } /** * Remove paths from the list to be used to fetch records. * * @param array $paths The paths to be removed. * @return P4Cms_Record_Query provide a fluent interface. */ public function removePaths($paths = array()) { if (!isset($paths) or !is_array($paths)) { throw new InvalidArgumentException('Cannot remove paths; argument must be an array.'); } $currentPaths = $this->getPaths() ?: array(); foreach ($paths as $path) { $index = array_search($path, $currentPaths); if ($index !== false) { array_splice($currentPaths, $index, 1); } } $this->setPaths($currentPaths); return $this; } /** * Set the record class to use when preparing the query for execution. * If no record class is set uses the base record class. * * @param object|string|null $class instance or name of a record class - null to clear. * @return P4Cms_Record_Query provides fluent interface. * @throws InvalidArgumentException if the given class is not a valid record class. */ public function setRecordClass($class) { $class = is_object($class) ? get_class($object) : $class; // only validate if class is not null. if ($class) { $this->_validateRecordClass($class); } $this->_recordClass = $class; return $this; } /** * Get the record class to use when preparing the query. * If no record class has been set, returns the base record class. * * @return string the name of the record class to use. */ public function getRecordClass() { return $this->_recordClass ?: static::RECORD_BASE_CLASS; } /** * Compose a P4_File_Query to be used in P4_File::fetchAll() calls. * * @param string $recordClass optional - a specific record class to influence storage paths. * @param P4Cms_Record_Adapter $adapter optional - storage adapter to use. * @return P4_File_Query A query object for use with P4_File::fetchAll() */ public function toFileQuery($recordClass = null, P4Cms_Record_Adapter $adapter = null) { // validate record class. $recordClass = $recordClass ?: $this->getRecordClass(); $this->_validateRecordClass($recordClass); // if no adapter given, use default. $adapter = $adapter ?: $recordClass::getDefaultAdapter(); // determine location of records in depot. $depotStoragePath = $recordClass::getDepotStoragePath($adapter); $storagePath = $recordClass::getStoragePath($adapter); // clone the current query options, so that later on we // don't have to undo the modifications below. $query = clone $this->_query; // update the filter to remove deleted records, unless include deleted is true if (!$this->getIncludeDeleted()) { $filter = "^headAction=...delete"; $filter = $query->getFilter() !== null && (string) $query->getFilter() !== '' ? '('. $query->getFilter() .') & '. $filter : $filter; $query->setFilter($filter); } // set limit fields - we have to do it here as we need to know recordClass // to ignore id and file content fields if ($this->_options[static::QUERY_LIMIT_FIELDS]) { // always include depotFile field $limitFields = array('depotFile'); $fields = (array) $this->_options[static::QUERY_LIMIT_FIELDS]; foreach ($fields as $field) { // ignore id and file content fields if ($field === $recordClass::getIdField() || ($recordClass::hasFileContentField() && $field === $recordClass::getFileContentField()) ) { continue; } $limitFields[] = 'attr-' . $field; } $query->setLimitFields($limitFields); } // modify the filter to limit results by depth, if required if ($this->getMaxDepth() !== null) { // restrict depth by filtering depot paths deeper than max depth. $filter = '^depotFile=' . $depotStoragePath . str_repeat('/*', $this->getMaxDepth() + 1) .'/...'; $filter = $query->getFilter() !== null ? '('. $query->getFilter() . ') & '. $filter : $filter; $query->setFilter($filter); } $filespecs = array(); // collect all paths, prepending storage path foreach ($this->getPaths() ?: array() as $path) { $filespecs[] = $storagePath . "/" . $path; } // collect all IDs, translating to filespec foreach ($this->getIds() ?: array() as $id) { $filespecs[] = $recordClass::idToFilespec($id, $adapter); } // do any required global touchup foreach ($filespecs as &$filespec) { if (!P4_File::hasRevspec($filespec)) { $filespec .= '#head'; } } // four cases for handling path filters: // null - set to entire storage path and append #head to hide pending adds. // empty array - leave empty (no results desired). // one path - simply set the one filespec directly. // multiple paths - put paths in a temp label to limit results. if ($this->getPaths() === null && $this->getIds() === null) { $query->setFilespecs($storagePath . "/...#head"); } else { switch (count($filespecs)) { case 0: case 1: $query->setFilespecs($filespecs); break; default: $label = P4_Label::makeTemp( array('View' => array($depotStoragePath . '/...')), null, $adapter->getConnection() ); $label->tag($filespecs); $query->setFilespecs($storagePath . '/...@' . $label->getId()); } } return $query; } /** * Verify that the given class name is a valid record class. * * @param string $class the class name to verify. * @throws InvalidArgumentException if the class does not exist or is not a record. */ protected function _validateRecordClass($class) { $base = static::RECORD_BASE_CLASS; // ensure class exists and is a valid record class. if (!class_exists($class) || (!is_subclass_of($class, $base) && $class !== $base) ) { throw new InvalidArgumentException("Invalid record class given."); } } }