- <?php
- /**
- * Perforce Swarm
- *
- * @copyright 2012 Perforce Software. All rights reserved.
- * @license Please see LICENSE.txt in top-level folder of this distribution.
- * @version <release>/<patch>
- */
-
- namespace Record\Key;
-
- use P4\Connection\ConnectionInterface as Connection;
- use P4\Filter\Utf8 as Utf8Filter;
- use P4\Key\Key;
- use P4\Model\Fielded\FieldedAbstract as FieldedModel;
- use P4\Model\Fielded\Iterator as FieldedIterator;
- use P4\OutputHandler\Limit;
- use Record\Exception\Exception;
- use Record\Exception\NotFoundException;
-
- /**
- * Provides persistent storage and indexing of data using perforce keys
- * and, optionally, perforce index/search functionality.
- */
- abstract class AbstractKey extends FieldedModel
- {
- /**
- * Key prefix should be akin to 'swarm-type-'
- * Key count should be in a separate namespace e.g. 'swarm-type:count'
- */
- const KEY_PREFIX = null;
- const KEY_COUNT = null;
-
- /**
- * The empty index value will be used for indexed fields with no/empty value.
- * This allows us to do a search for empty values efficiently.
- * Normal values will be hex encoded, the empty value will not and
- * should be selected so it can never collide with hex.
- */
- const EMPTY_INDEX_VALUE = 'empty';
-
- const FETCH_SEARCH = 'search';
- const FETCH_BY_KEYWORDS = 'keywords';
- const FETCH_KEYWORDS_FIELDS = 'keywordsFields';
- const FETCH_MAXIMUM = 'maximum';
- const FETCH_AFTER = 'after';
- const FETCH_BY_IDS = 'ids';
- const FETCH_TOTAL_COUNT = 'totalCount';
-
- protected $p4 = null;
- protected $id = null;
- protected $original = null;
- protected $isFromKey = false;
-
- /**
- * Define the fields that make up this model.
- *
- * By default we map an id 'field' to getId/setId during construction.
- * If you declare fields in a sub-class, you don't need to redeclare
- * the id field unless you desire different settings.
- *
- * If you want a field to be automatically indexed (so that you
- * can use it in a search expression), set a 'index' property
- * on the field to a unique index code (e.g. 'index' => 1001).
- *
- * @var array the pre-defined fields that make up this model.
- */
- protected $fields = array();
-
- /**
- * Instantiate the model and set the connection to use.
- * Extends parent to automatically add an id field if one
- * hasn't already been defined.
- *
- * @param Connection $connection optional - a connection to use for this instance.
- */
- public function __construct(Connection $connection = null)
- {
- parent::__construct($connection);
-
- if (!in_array('id', $this->fields) && !isset($this->fields['id'])) {
- $this->fields =
- array(
- 'id' => array(
- 'accessor' => 'getId',
- 'mutator' => 'setId',
- 'unstored' => true
- )
- )
- + $this->fields;
- }
- }
-
- /**
- * Returns the id or null if none set.
- *
- * @return int|string|null
- */
- public function getId()
- {
- return static::decodeId($this->id);
- }
-
- /**
- * Set an id for this record. If creating a new record you must
- * leave the id blank and allow an auto-incrementing value to be
- * generated on save.
- *
- * @param int|string|null $id the id to use or null for auto
- * @return AbstractKey to maintain a fluent interface
- */
- public function setId($id)
- {
- $this->id = strlen($id) ? static::encodeId($id) : null;
-
- return $this;
- }
-
- /**
- * Set a field's raw value (avoids mutator).
- * Extended to ensure string values are valid utf8.
- *
- * @param string $field the name of the field to set the value of.
- * @param mixed $value the value to set in the field.
- * @return AbstractKey provides a fluent interface
- */
- public function setRawValue($field, $value)
- {
- $utf8 = new Utf8Filter;
- $value = $utf8->filter($value);
-
- return parent::setRawValue($field, $value);
- }
-
- /**
- * Retrieves the specified record. Throws if an invalid/unknown
- * id is specified.
- *
- * @param string|int $id the entry id to be retrieved
- * @param Connection $p4 the connection to use
- * @return AbstractKey to maintain a fluent interface
- * @throws NotFoundException on unknown id
- * @throws \InvalidArgumentException on badly formatted/typed id
- */
- public static function fetch($id, Connection $p4)
- {
- try {
- return static::keyToModel(Key::fetch(static::encodeId($id), $p4));
- } catch (\P4\Counter\Exception\NotFoundException $e) {
- throw new NotFoundException($e->getMessage());
- }
- }
-
- /**
- * Verifies if the specified record(s) exists.
- * Its better to call 'fetch' directly in a try block if you will
- * be retrieving the record on success.
- *
- * @param string|int|array $id the entry id or an array of ids to filter
- * @param Connection $p4 the connection to use
- * @return bool|array true/false for single arg, array of existent ids for array input
- */
- public static function exists($id, Connection $p4)
- {
- // before we muck with things; capture if it's plural or singular mode
- $plural = is_array($id);
-
- // normalize the input to an array of non-empty encoded ids
- $ids = array_filter((array) $id, 'strlen');
- foreach ($ids as &$id) {
- $id = static::encodeId($id);
- }
-
- // fetch the potential IDs, do this at key level to save a little object overhead
- // after fetching, translate the key ids back into public ids
- $keys = Key::fetchAll(array(Key::FETCH_BY_IDS => $ids), $p4)->invoke('getId');
- $ids = array();
- foreach ($keys as $key) {
- $ids[] = static::decodeId($key);
- }
-
- // return the list of ids or simply a bool
- return $plural ? $ids : count($ids) != 0;
- }
-
- /**
- * Retrieves all records that match the passed options.
- *
- * @param array $options an optional array of search conditions and/or options
- * supported options are:
- * FETCH_SEARCH - set to a search expression to fetch by.
- * only indexed fields can be searched and
- * they must be referred to by index code.
- * FETCH_BY_KEYWORDS - set to a string to match against all indexed fields
- * FETCH_KEYWORDS_FIELDS - set to a list of fields to limit keywords search
- * FETCH_MAXIMUM - set to integer value to limit to the first
- * 'max' number of entries.
- * FETCH_AFTER - set to an id _after_ which we start collecting
- * FETCH_BY_IDS - provide an array of ids to fetch.
- * not compatible with FETCH_SEARCH or FETCH_AFTER.
- * FETCH_TOTAL_COUNT - valid only for searching, set to true to keep track
- * of all valid entries (including those skipped due to
- * FETCH_MAXIMUM or FETCH_AFTER options), number of total
- * 'counted' entries will be set into 'totalCount'
- * property of the models iterator
- * @param Connection $p4 the perforce connection to run on
- * @return FieldedIterator the list of zero or more matching record objects
- * @throws \InvalidArgumentException invalid combinations of options
- */
- public static function fetchAll(array $options, Connection $p4)
- {
- // normalize options
- $options += array(
- static::FETCH_SEARCH => null,
- static::FETCH_BY_KEYWORDS => null,
- static::FETCH_KEYWORDS_FIELDS => null,
- static::FETCH_MAXIMUM => null,
- static::FETCH_AFTER => null,
- static::FETCH_BY_IDS => null,
- static::FETCH_TOTAL_COUNT => false
- );
-
- // prepare search expression for keyword search
- if (strlen($options[static::FETCH_BY_KEYWORDS])) {
- $query = array();
- $words = static::splitIntoWords($options[static::FETCH_BY_KEYWORDS]);
-
- // limit the fields we actually search keywords against either to the set
- // specified in options or, if not specified, to the fields of the model
- $model = new static;
- $fields = $options[static::FETCH_KEYWORDS_FIELDS] ?: $model->getFields();
- $fields = array_filter((array) $fields, array($model, 'getIndexCode'));
-
- // make search expression for searching words in given fields
- $queries = array();
- foreach ($words as $word) {
- $query = array();
- foreach ($fields as $field) {
- // lowercase the word if field is a word index (as they are case-insensitive)
- $searchValue = $model->isWordIndex($field)
- ? static::lowercase($word)
- : $word;
- $code = $model->getIndexCode($field);
- $query[] = $code . '=' . static::encodeIndexValue($searchValue) . '*';
- }
- $queries[] = $query ? '(' . implode(' | ', $query) . ')' : null;
- }
- $options[static::FETCH_SEARCH] .= $queries ? ' (' . implode(' ', $queries) . ')' : '';
- }
-
- // throw if options are clearly invalid.
- if ($options[static::FETCH_AFTER] && is_array($options[static::FETCH_BY_IDS])) {
- throw new \InvalidArgumentException(
- 'It is not valid to pass fetch by ids and also specify fetch after or fetch search.'
- );
- }
-
- // fetch total count is valid only if searching
- if ($options[static::FETCH_TOTAL_COUNT] && $options[static::FETCH_SEARCH] === null) {
- throw new \InvalidArgumentException(
- 'totalCount option may be enabled only if search option is not null'
- );
- }
-
- // must adjust 'after' constraint to use internal id encoding.
- $options[static::FETCH_AFTER] = static::encodeId($options[static::FETCH_AFTER]);
-
- // fetch the models:
- // - if a search expression was specified, run a search and return those results
- // - otherwise, add a fetch by name filter to limit the results to only our key prefix
- // assuming the user hasn't specified explicit IDs to fetch.
- if (strlen($options[static::FETCH_SEARCH])) {
- $models = static::fetchAllBySearch($options, $p4);
- } else {
- if (!is_array($options[static::FETCH_BY_IDS])) {
- $options[Key::FETCH_BY_NAME] = static::KEY_PREFIX . '*';
- }
-
- $models = static::fetchAllNoSearch($options, $p4);
- }
-
- // set 'lastSeen' property to indicate the id of the last model fetched
- // this can be useful if the list is later filtered to remove some entries
- $models->setProperty('lastSeen', $models->count() ? $models->last()->getId() : null);
-
- return $models;
- }
-
- /**
- * Saves the records values and updates indexes as needed.
- *
- * @return AbstractKey to maintain a fluent interface
- */
- public function save()
- {
- // if we have no id attempt to generate one.
- if (!strlen($this->id)) {
- $this->id = $this->makeId();
- }
-
- // attempt to fetch the currently stored version of this record if one exists.
- try {
- $stored = static::fetch($this->getId(), $this->getConnection());
- } catch (NotFoundException $e) {
- $stored = null;
- }
-
- // allow extending classes to implement upgrade logic
- $this->upgrade($stored);
-
- // get raw values to write to storage
- // if we started with a copy from storage, only overwrite fields that have been changed
- // this minimizes race conditions where other processes are updating the same record
- $values = $this->getRawValues();
- $original = $this->original;
- $stored = $stored ? $stored->getRawValues() : null;
- if ($original && $stored !== null) {
- $unset = array_diff_key($original, $values);
- foreach ($values as $key => $value) {
- if (array_key_exists($key, $original) && $original[$key] === $value) {
- unset($values[$key]);
- }
- }
- $values += $stored;
- $values = array_diff_key($values, $unset);
- }
-
- // exclude any 'unstored' fields
- $unstored = array();
- foreach ($this->fields as $field => $properties) {
- if (isset($properties['unstored']) && $properties['unstored'] && array_key_exists($field, $values)) {
- $unstored[$field] = $values[$field];
- unset($values[$field]);
- }
- }
-
- // take care of indexing
- $this->updateIndexedFields($values, $stored);
-
- // save the actual record data
- $key = new Key($this->getConnection());
- $key->setId($this->id);
- $key->set(
- // follow zend's json::encode approach to options for consistency
- json_encode($values, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP)
- );
-
- // update values to reflect the latest merged plus unstored values
- // update original so future saves correctly identify changed fields
- $this->values = $values + $unstored;
- $this->original = $values;
-
- return $this;
- }
-
- /**
- * Deletes the current record and attempts to remove indexes.
- *
- * @return AbstractKey to maintain a fluent interface
- */
- public function delete()
- {
- // attempt to fetch the currently stored version of this record if one exists.
- try {
- $stored = static::fetch($this->getId(), $this->getConnection())->getRawValues();
- } catch (NotFoundException $e) {
- $stored = null;
- }
-
- // remove the indices first (once the key value is
- // gone we lose the data we need to clear indices)
- $this->updateIndexedFields(null, $stored);
-
- // now nuke the key - avoid fetch to skip a redundant exists call.
- $key = new Key($this->getConnection());
- $key->setId($this->id)
- ->delete();
-
- return $this;
- }
-
- /**
- * Hook for implementing upgrade logic in concrete records.
- * This method is called near the beginning of save(), just after we fetch the old record.
- *
- * @param AbstractKey|null $stored an instance of the old record from storage or null if adding
- */
- protected function upgrade(AbstractKey $stored = null)
- {
- // nothing to do here -- concrete classes may extend to implement upgrades
- }
-
- /**
- * This method should be called just prior to saving out new values.
- * It will de-index the currently stored index fields for this record
- * and save new indexes for the passed values.
- *
- * @param array $values an array of new values
- * @param array|null $stored an array of old values
- * @return AbstractKey to maintain a fluent interface
- */
- protected function updateIndexedFields($values, array $stored = null)
- {
- $values = (array) $values;
- $isAdd = !is_array($stored);
-
- // if its an add, pass false as default 'old' value to indicate de-indexing can be skipped
- // if its an edit, we pass null as default 'old' value to indicate the old value was empty
- $oldDefault = $isAdd ? false : null;
-
- // we now want to update our index fields which takes a few steps:
- // - loop each field skipping any that lack 'index codes'
- // - calculate the new and old values
- // - if its an add or the values differ, update index
- foreach ($this->getFields() as $field) {
- $code = $this->getIndexCode($field);
- if ($code !== false) {
- $new = isset($values[$field]) ? $values[$field] : null;
- $old = isset($stored[$field]) ? $stored[$field] : $oldDefault;
-
- // if this is an add or the value has changed, update index
- if ($isAdd || $new !== $old) {
- $this->index($code, $field, $new, $old);
- }
- }
- }
-
- return $this;
- }
-
- /**
- * Index this record under a given name with given value(s).
- * This makes it easy for us to find the record in the future
- * by searching the named index for the given values.
- *
- * @param int $code the index code/number of the field
- * @param string $name the field/name of the index
- * @param string|array|null $value one or more values to index
- * @param string|array|null|false $remove one or more old values that need to be de-indexed
- * pass false if this is an add and de-index can be skipped.
- * @return AbstractKey provides fluent interface
- * @throws \Exception if no id has been set
- */
- protected function index($code, $name, $value, $remove)
- {
- if (!strlen($this->getId())) {
- throw new \Exception('Cannot index, no ID has been set.');
- }
-
- // split $value into lowercase words if configured to index individual words
- if ($this->isWordIndex($name)) {
- $value = static::splitIntoWords($value, true);
- $remove = static::splitIntoWords($remove, true);
- }
-
- // flatten associative arrays into 'key:value' strings if indexFlatten is set
- if (isset($this->fields[$name]['indexFlatten']) && $this->fields[$name]['indexFlatten']) {
- $value = static::flattenForIndex((array) $value);
- $remove = $remove !== false ? static::flattenForIndex((array) $remove) : false;
- }
-
- // only index keys (ignore values) if indexOnlyKeys is set
- if (isset($this->fields[$name]['indexOnlyKeys']) && $this->fields[$name]['indexOnlyKeys']) {
- $value = array_keys((array) $value);
- $remove = $remove !== false ? array_keys((array) $remove) : $remove;
- }
-
- // if old indices were specified filter out null or empty values
- // and de-index anything left over.
- if ($remove !== false) {
- $remove = array_filter((array) $remove, 'strlen');
-
- // encode the value(s) for index removal
- $remove = array_map(array($this, 'encodeIndexValue'), $remove);
-
- // if no old values are present, de-index the empty value
- // this isn't encoded to avoid collisions with actual entries.
- $remove = $remove ?: array(static::EMPTY_INDEX_VALUE);
-
- // remove the index - note we need to use numeric codes for the name
- $this->getConnection()->run(
- 'index',
- array('-a', $code, '-d', $this->id),
- implode(' ', $remove)
- );
- }
-
- // add indexes for all non-empty/null values in our new version
- $value = array_filter((array) $value, 'strlen');
-
- // encode the value(s) for index storage - we need to do this because
- // we want literal matches and the indexer splits on hyphens, etc.
- $value = array_map(array($this, 'encodeIndexValue'), $value);
-
- // if no values are present, index the empty value this
- // isn't encoded to avoid collisions with actual entries.
- $value = $value ?: array(static::EMPTY_INDEX_VALUE);
-
- // write the index - note we need to use numeric codes for the name
- $this->getConnection()->run(
- 'index',
- array('-a', $code, $this->id),
- implode(' ', $value)
- );
-
- return $this;
- }
-
- /**
- * Return $value split into unique words suitable for indexing or searching.
- * If $value is array of strings, return list of all unique words from all
- * values of the array.
- *
- * @param null|false|string|array $value string or list of strings to split into words
- * @return boolean $lowercase optional - whether to also lowercase words (false by default)
- * @param bool $trim optional - whether to trim the words (true by default), we need
- * this for upgrading purposes to get the words in the 'old' way
- * @param null|false|string|array $value string or list of strings to split into words
- */
- protected static function splitIntoWords($value, $lowercase = false, $trim = true)
- {
- // do nothing if passed null or false
- if ($value === null || $value === false) {
- return $value;
- }
-
- $words = array();
- foreach ((array) $value as $string) {
- $candidates = preg_split('/[\s,\.]+/', $string);
- foreach ($candidates as $word) {
- // trim the word if requested - remove the leading and trailing punctuation, parenthesis, etc.
- $words[] = $trim ? trim($word, '`”’"\'!?*~:;_()<>[]{}') : $word;
- }
- }
-
- // remove duplicates and empty words
- $words = array_unique(array_filter($words, 'strlen'));
-
- return $lowercase ? array_map(array(get_class(), 'lowercase'), $words) : $words;
- }
-
- /**
- * Convert associative array into a list of strings with 'key:value'
- * as a preparation for indexing. If value is array with values
- * val1, val2,... then output list will contain all strings
- * 'key:val1', 'key:val2', etc.
- *
- * @param array $array array value to flatten
- * @return array input value converted to a list
- * of 'key:value' strings
- */
- protected static function flattenForIndex(array $array)
- {
- $result = array();
- foreach ($array as $key => $values) {
- // include all non-empty values
- $values = array_filter((array) $values, 'strlen');
- foreach ($values as $value) {
- $result[] = $key . ':' . $value;
- }
- }
-
- return $result;
- }
-
- /**
- * Convert given string to lowercase using UTF-8 safe conversion if possible.
- *
- * @param string $value string to lowercase
- * @return string lowercase value
- */
- protected static function lowercase($value)
- {
- if (function_exists('mb_strtolower')) {
- return mb_strtolower($value, 'UTF-8');
- }
-
- return strtolower($value);
- }
-
- /**
- * Returns the index code to use for a specified field.
- *
- * @param string $field the field name to find a code for
- * @return int|bool the code for the requested field or false
- */
- protected function getIndexCode($field)
- {
- if (isset($this->fields[$field]['index'])
- && ctype_digit($this->fields[$field]['index'])
- ) {
- return (int) $this->fields[$field]['index'];
- }
-
- return false;
- }
-
- /**
- * Check whether given field is configured to index individual words (returns true)
- * or not (returns false).
- *
- * @param string $field the field name to check for indexing words
- * @return bool true if field is configured to index words, false otherwise
- */
- protected function isWordIndex($field)
- {
- return isset($this->fields[$field])
- && is_array($this->fields[$field])
- && isset($this->fields[$field]['indexWords'])
- && $this->fields[$field]['indexWords'];
- }
-
- /**
- * Breaks out the case of fetching by doing a 'p4 search' and then populating
- * the resulting records. Options such as max/after will still be honored.
- *
- * @param array $options normalized fetch options (e.g. search/max/after)
- * @param Connection $p4 the perforce connection to run on
- * @return FieldedIterator the list of zero or more matching record objects
- */
- protected static function fetchAllBySearch(array $options, Connection $p4)
- {
- // pull out search/max/after/countAll
- $max = $options[static::FETCH_MAXIMUM];
- $after = $options[static::FETCH_AFTER];
- $search = $options[static::FETCH_SEARCH];
- $countAll = $options[static::FETCH_TOTAL_COUNT];
- $params = array($search);
- $isAfter = false;
-
- // if we are not counting all and we have a max but no after
- // we can use -m on new enough servers as an optimization
- if (!$countAll && $max && !$after && $p4->isServerMinVersion('2013.1')) {
- array_unshift($params, $max);
- array_unshift($params, '-m');
- }
-
- // setup an output handler to ensure max and after are honored
- $prefix = static::KEY_PREFIX;
- $handler = new Limit;
- $handler->setMax($max)
- ->setCountAll($countAll)
- ->setFilterCallback(
- function ($data) use ($after, &$isAfter, $prefix) {
- // be defensive, exclude any ids that lack our key prefix
- if (strpos($data, $prefix) !== 0) {
- return Limit::FILTER_EXCLUDE;
- }
-
- if ($after && !$isAfter) {
- $isAfter = $after == $data;
- return Limit::FILTER_SKIP;
- }
-
- return Limit::FILTER_INCLUDE;
- }
- );
-
- $ids = $p4->runHandler($handler, 'search', $params)->getData();
- $keys = Key::fetchAll(array(Key::FETCH_BY_IDS => $ids), $p4);
- $models = new FieldedIterator;
- foreach ($keys as $key) {
- $model = static::keyToModel($key);
- $models[$model->getId()] = $model;
- }
-
- // if caller asks for total count, set 'totalCount' property on the iterator
- // total count also includes entries skipped by the output handler and thus
- // this number may be different from number of entries in the iterator
- if ($countAll) {
- $models->setProperty('totalCount', $handler->getTotalCount());
- }
-
- return $models;
- }
-
- /**
- * Breaks out the case of fetching everything sans 'p4 search' filters.
- * We could still have options for max, after and our name filter will
- * be present to limit the returned counter results.
- *
- * @param array $options a normalized array of filters
- * @param Connection $p4 the perforce connection to run on
- * @return FieldedIterator the list of zero or more matching record objects
- */
- protected static function fetchAllNoSearch(array $options, Connection $p4)
- {
- // if ids were specified, encode them so key fetchall knows what to do
- foreach ((array) $options[static::FETCH_BY_IDS] as $key => $id) {
- $options[static::FETCH_BY_IDS][$key] = static::encodeId($options[static::FETCH_BY_IDS][$key]);
- }
-
- $keys = Key::fetchAll($options, $p4);
- $models = new FieldedIterator;
- foreach ($keys as $key) {
- $model = static::keyToModel($key);
- $models[$model->getId()] = $model;
- }
-
- return $models;
- }
-
- /**
- * Turn the passed key into a record.
- *
- * If a callable is passed for the optional class name param it will be passed the
- * raw record data and the key object. The callable is expected to return the class
- * name to be used or null to fallback to static.
- *
- * @param Key $key the key to 'record'ize
- * @param string|callable $className optional - class name to use, static by default
- * @return AbstractKey the record based on the passed key's data
- */
- protected static function keyToModel($key, $className = null)
- {
- // get the value from the key and json decode to an array
- $data = json_decode($key->get(), true);
-
- // determine the class we are instantiating
- $className = is_callable($className) ? $className($data, $key) : $className;
- $className = $className ?: get_called_class();
-
- // actually instantiate and setup the model
- $model = new $className($key->getConnection());
- $model->setRawValues((array) $data);
- $model->id = $key->getId();
-
- // we want to record the original values that we fetched
- // so that we can determine what has changed on save
- $model->original = (array) $data;
-
- // record the fact that this model was generated from a key
- // most likely this implies it came from storage.
- $model->isFromKey = true;
-
- return $model;
- }
-
- /**
- * Takes a friendly id (e.g. 2) and encodes it to the actual storage id
- * used for the underlying key (e.g. swarm-type-00000002)
- *
- * If a KEY_COUNT is defined for this model numeric IDs will be 0 padded
- * to 10 digits. For model's lacking a KEY_COUNT we assume you want
- * manually selected IDs and skip padding. Non numeric ids won't be padded
- * regardless.
- *
- * @param string|int $id the user facing id
- * @return string the stored id used by p4 key
- */
- protected static function encodeId($id)
- {
- // just leave null enough alone
- if (!strlen($id)) {
- return $id;
- }
-
- // if we have a KEY_COUNT and its a purley numeric ID, pad it!
- if (static::KEY_COUNT && $id == (string) (int) $id) {
- $id = str_pad($id, 10, '0', STR_PAD_LEFT);
- }
-
- // prefix and return regardless of padding needs
- return static::KEY_PREFIX . $id;
- }
-
- /**
- * Takes a storage id used for the underlying key and turns it into
- * a friendly id for external consumption.
- *
- * If the ID appears to be 0 padded and we have a KEY_COUNT we'll
- * return an int cast version which ends up stripping off the leading
- * zeros (which we most likely put there to start with).
- *
- * If this model is configured with a key prefix, but the given id
- * does not have a matching prefix, we can't decode it and return null.
- *
- * @param string $id the stored id used by p4 key
- * @return string|int the user facing id
- */
- protected static function decodeId($id)
- {
- // just leave null enough alone
- if ($id === null) {
- return $id;
- }
-
- // if we have a key prefix, but id does not start with it, return null
- $prefix = static::KEY_PREFIX;
- if ($prefix && strpos($id, $prefix) !== 0) {
- return null;
- }
-
- // always need to strip the prefix
- $id = substr($id, strlen($prefix));
-
- // if we have a key prefix and a 10 digit numeric id,
- // int cast it which effectively removes the leading 0's
- if ($prefix && strlen($id) == 10 && $id == (string) (int) $id) {
- $id = (int) $id;
- }
-
- return $id;
- }
-
- /**
- * Called when an auto-generated ID is required for an entry.
- *
- * @return string a new auto-generated id. the id will be 'encoded'.
- * @throws Exception if called when no KEY_COUNT has been specified for the model.
- */
- protected function makeId()
- {
- // if we lack a KEY_COUNT we can't do much so blow up
- if (!static::KEY_COUNT) {
- throw new Exception(
- 'Cannot generate an auto-incrementing id. No key count has been set.'
- );
- }
-
- // get an auto-incrementing id via our 'count' counter
- $key = new Key($this->getConnection());
- $key->setId(static::KEY_COUNT);
- $id = $key->increment();
-
- return static::encodeId($id);
- }
-
- /**
- * Encodes the passed index value. This is needed to avoid having
- * the value break on word boundaries (e.g. the '-' character) and
- * produce un-intended sub matches.
- *
- * @param string $value the raw value
- * @return string an encoded version of the value
- */
- protected static function encodeIndexValue($value)
- {
- return strtoupper(bin2hex($value));
- }
-
- /**
- * Decodes the passed index value.
- *
- * @param string $value the encoded index value
- * @return string a decoded version of the value
- */
- protected static function decodeIndexValue($value)
- {
- return pack('H*', $value);
- }
-
- /**
- * Produces a 'p4 search' expression for the given field/value pairs.
- *
- * The conditions array should contain 'indexed' field names as
- * keys and the strings or array of strings to search for as values.
- * Array values will be converted into an OR-joined conditions
- * and appended to the expression as a sub-query.
- *
- * @param array $conditions field/value pairs to search for
- * @return string a query expression suitable for use with p4 search
- */
- protected static function makeSearchExpression($conditions)
- {
- $query = "";
- $model = new static;
- foreach ($conditions as $field => $value) {
- $code = $model->getIndexCode($field);
- // skip non-indexed fields
- if ($code === false) {
- continue;
- }
-
- // normalize value to an array and remove empty/null entries
- $values = array_filter((array) $value, 'strlen');
-
- // lowercase values if field is a word index (as they are case-insensitive)
- if ($model->isWordIndex($field)) {
- $values = array_map(array(get_class(), 'lowercase'), $values);
- }
-
- // encode the value(s) to compare with indexed data
- $values = array_map(array(get_class(), 'encodeIndexValue'), $values);
-
- // if normalization results in no search values and we weren't
- // specifically passed false, nothing to do for this one.
- if (!$values && $value !== false) {
- continue;
- }
-
- // if we made it here and values is false; must have been
- // passed false. Use the empty value.
- $values = $values ?: array(static::EMPTY_INDEX_VALUE);
-
- // turn our values array into conditions using the field's code
- $conditions = array();
- foreach ($values as $value) {
- $conditions[] = $code . '=' . $value;
- }
- $expression = implode(' | ', $conditions);
- $query .= count($conditions) > 1 ? '(' . $expression . ') ' : $expression . ' ';
- }
-
- return trim($query);
- }
- }