<?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);
}
}