/**
* Tests the child() method.
*/
public function testChild()
{
$this->assertFalse(Element::child('#property'));
$this->assertTrue(Element::child('property'));
$this->assertTrue(Element::child('property#'));
}
/**
* Extracts the positive keywords from a keys array.
*
* @param array $keys
* A search keys array, as specified by
* \Drupal\search_api\Query\QueryInterface::getKeys().
*
* @return string[]
* An array of all unique positive keywords contained in the keys array.
*/
protected function flattenKeysArray(array $keys)
{
if (!empty($keys['#negation'])) {
return array();
}
$keywords = array();
foreach ($keys as $i => $key) {
if (!Element::child($i)) {
continue;
}
if (is_array($key)) {
$keywords += $this->flattenKeysArray($key);
} else {
$keywords[$key] = $key;
}
}
return $keywords;
}
/**
* Preprocesses the search keywords.
*
* Calls processKey() for individual strings.
*
* @param array|string $keys
* Either a parsed keys array, or a single keywords string.
*/
protected function processKeys(&$keys)
{
if (is_array($keys)) {
foreach ($keys as $key => &$v) {
if (Element::child($key)) {
$this->processKeys($v);
if ($v === '') {
unset($keys[$key]);
}
}
}
} else {
$this->processKey($keys);
}
}
/**
* Creates a SELECT query for given search keys.
*
* Used as a helper method in createDbQuery() and createFilterCondition().
*
* @param string|array $keys
* The search keys, formatted like the return value of
* \Drupal\search_api\Query\QueryInterface::getKeys(), but preprocessed
* according to internal requirements.
* @param array $fields
* The fulltext fields on which to search, with their names as keys mapped
* to internal information about them.
* @param array $all_fields
* Internal information about all indexed fields on the index.
* @param \Drupal\search_api\IndexInterface $index
* The index we're searching on.
*
* @return \Drupal\Core\Database\Query\SelectInterface
* A SELECT query returning item_id and score (or only item_id, if
* $keys['#negation'] is set).
*/
protected function createKeysQuery($keys, array $fields, array $all_fields, IndexInterface $index) {
if (!is_array($keys)) {
$keys = array(
'#conjunction' => 'AND',
$keys,
);
}
$neg = !empty($keys['#negation']);
$conj = $keys['#conjunction'];
$words = array();
$nested = array();
$negated = array();
$db_query = NULL;
$mul_words = FALSE;
$neg_nested = $neg && $conj == 'AND';
foreach ($keys as $i => $key) {
if (!Element::child($i)) {
continue;
}
if (is_scalar($key)) {
$words[] = $key;
}
elseif (empty($key['#negation'])) {
if ($neg) {
// If this query is negated, we also only need item IDs from
// subqueries.
$key['#negation'] = TRUE;
}
$nested[] = $key;
}
else {
$negated[] = $key;
}
}
$subs = count($words) + count($nested);
$not_nested = ($subs <= 1 && count($fields) == 1) || ($neg && $conj == 'OR' && !$negated);
if ($words) {
// All text fields in the index share a table. Get name from the first.
$field = reset($fields);
$db_query = $this->database->select($field['table'], 't');
$mul_words = count($words) > 1;
if ($neg_nested) {
$db_query->fields('t', array('item_id', 'word'));
}
elseif ($neg) {
$db_query->fields('t', array('item_id'));
}
elseif ($not_nested) {
$db_query->fields('t', array('item_id', 'score'));
}
else {
$db_query->fields('t', array('item_id', 'score', 'word'));
}
$db_query->condition('word', $words, 'IN');
$db_query->condition('field_name', array_map(array(__CLASS__, 'getTextFieldName'), array_keys($fields)), 'IN');
}
if ($nested) {
$word = '';
foreach ($nested as $k) {
$query = $this->createKeysQuery($k, $fields, $all_fields, $index);
if (!$neg) {
$word .= ' ';
$var = ':word' . strlen($word);
$query->addExpression($var, 'word', array($var => $word));
}
if (!isset($db_query)) {
$db_query = $query;
}
elseif ($not_nested) {
$db_query->union($query, 'UNION');
}
else {
$db_query->union($query, 'UNION ALL');
}
}
}
if (isset($db_query) && !$not_nested) {
$db_query = $this->database->select($db_query, 't');
$db_query->addField('t', 'item_id', 'item_id');
if (!$neg) {
$db_query->addExpression('SUM(t.score)', 'score');
$db_query->groupBy('t.item_id');
}
if ($conj == 'AND' && $subs > 1) {
$var = ':subs' . ((int) $subs);
if (!$db_query->getGroupBy()) {
$db_query->groupBy('t.item_id');
}
if ($mul_words) {
$db_query->having('COUNT(DISTINCT t.word) >= ' . $var, array($var => $subs));
}
else {
$db_query->having('COUNT(t.word) >= ' . $var, array($var => $subs));
}
}
}
if ($negated) {
if (!isset($db_query) || $conj == 'OR') {
if (isset($db_query)) {
// We are in a rather bizarre case where the keys are something like
// "a OR (NOT b)".
$old_query = $db_query;
}
// We use this table because all items should be contained exactly once.
$db_info = $this->getIndexDbInfo($index);
$db_query = $this->database->select($db_info['index_table'], 't');
$db_query->addField('t', 'item_id', 'item_id');
if (!$neg) {
$db_query->addExpression(':score', 'score', array(':score' => self::SCORE_MULTIPLIER));
$db_query->distinct();
}
}
if ($conj == 'AND') {
foreach ($negated as $k) {
$db_query->condition('t.item_id', $this->createKeysQuery($k, $fields, $all_fields, $index), 'NOT IN');
}
}
else {
$or = new Condition('OR');
foreach ($negated as $k) {
$or->condition('t.item_id', $this->createKeysQuery($k, $fields, $all_fields, $index), 'NOT IN');
}
if (isset($old_query)) {
$or->condition('t.item_id', $old_query, 'NOT IN');
}
$db_query->condition($or);
}
}
if ($neg_nested) {
$db_query = $this->database->select($db_query, 't')->fields('t', array('item_id'));
}
return $db_query;
}
/**
* Creates a SELECT query for given search keys.
*
* Used as a helper method in createDbQuery() and createDbCondition().
*
* @param string|array $keys
* The search keys, formatted like the return value of
* \Drupal\search_api\Query\QueryInterface::getKeys(), but preprocessed
* according to internal requirements.
* @param array $fields
* The fulltext fields on which to search, with their names as keys mapped
* to internal information about them.
* @param array $all_fields
* Internal information about all indexed fields on the index.
* @param \Drupal\search_api\IndexInterface $index
* The index we're searching on.
*
* @return \Drupal\Core\Database\Query\SelectInterface
* A SELECT query returning item_id and score (or only item_id, if
* $keys['#negation'] is set).
*/
protected function createKeysQuery($keys, array $fields, array $all_fields, IndexInterface $index)
{
if (!is_array($keys)) {
$keys = array('#conjunction' => 'AND', $keys);
}
$neg = !empty($keys['#negation']);
$conj = $keys['#conjunction'];
$words = array();
$nested = array();
$negated = array();
$db_query = NULL;
$mul_words = FALSE;
$neg_nested = $neg && $conj == 'AND';
$match_parts = !empty($this->configuration['partial_matches']);
$keyword_hits = array();
foreach ($keys as $i => $key) {
if (!Element::child($i)) {
continue;
}
if (is_scalar($key)) {
$words[] = $key;
} elseif (empty($key['#negation'])) {
if ($neg) {
// If this query is negated, we also only need item IDs from
// subqueries.
$key['#negation'] = TRUE;
}
$nested[] = $key;
} else {
$negated[] = $key;
}
}
$word_count = count($words);
$subs = $word_count + count($nested);
$not_nested = $subs <= 1 && count($fields) == 1 || $neg && $conj == 'OR' && !$negated;
if ($words) {
// All text fields in the index share a table. Get name from the first.
$field = reset($fields);
$db_query = $this->database->select($field['table'], 't');
$mul_words = $word_count > 1;
if ($neg_nested) {
$db_query->fields('t', array('item_id', 'word'));
} elseif ($neg) {
$db_query->fields('t', array('item_id'));
} elseif ($not_nested) {
$db_query->fields('t', array('item_id', 'score'));
} else {
$db_query->fields('t', array('item_id', 'score', 'word'));
}
if (!$match_parts) {
$db_query->condition('word', $words, 'IN');
} else {
$db_or = new Condition('OR');
// GROUP BY all existing non-grouped, non-aggregated columns – except
// "word", which we remove since it will be useless to us in this case.
$columns =& $db_query->getFields();
unset($columns['word']);
foreach (array_keys($columns) as $column) {
$db_query->groupBy($column);
}
foreach ($words as $i => $word) {
$db_or->condition('t.word', '%' . $this->database->escapeLike($word) . '%', 'LIKE');
// Add an expression for each keyword that shows whether the indexed
// word matches that particular keyword. That way we don't return a
// result multiple times if a single indexed word (partially) matches
// multiple keywords. We also remember the column name so we can
// afterwards verify that each word matched at least once.
$alias = 'w' . $i;
$alias = $db_query->addExpression("t.word LIKE '%" . $this->database->escapeLike($word) . "%'", $alias);
$db_query->groupBy($alias);
$keyword_hits[] = $alias;
}
// Also add expressions for any nested queries.
for ($i = $word_count; $i < $subs; ++$i) {
$alias = 'w' . $i;
$alias = $db_query->addExpression('0', $alias);
$db_query->groupBy($alias);
$keyword_hits[] = $alias;
}
$db_query->condition($db_or);
}
$db_query->condition('field_name', array_map(array(__CLASS__, 'getTextFieldName'), array_keys($fields)), 'IN');
}
if ($nested) {
$word = '';
foreach ($nested as $i => $k) {
$query = $this->createKeysQuery($k, $fields, $all_fields, $index);
if (!$neg) {
if (!$match_parts) {
$word .= ' ';
$var = ':word' . strlen($word);
$query->addExpression($var, 'word', array($var => $word));
} else {
$i += $word_count;
for ($j = 0; $j < $subs; ++$j) {
$alias = isset($keyword_hits[$j]) ? $keyword_hits[$j] : "w{$j}";
$keyword_hits[$j] = $query->addExpression($i == $j ? '1' : '0', $alias);
}
}
}
if (!isset($db_query)) {
$db_query = $query;
} elseif ($not_nested) {
$db_query->union($query, 'UNION');
} else {
$db_query->union($query, 'UNION ALL');
}
}
}
if (isset($db_query) && !$not_nested) {
$db_query = $this->database->select($db_query, 't');
$db_query->addField('t', 'item_id', 'item_id');
if (!$neg) {
$db_query->addExpression('SUM(t.score)', 'score');
$db_query->groupBy('t.item_id');
}
if ($conj == 'AND' && $subs > 1) {
$var = ':subs' . (int) $subs;
if (!$db_query->getGroupBy()) {
$db_query->groupBy('t.item_id');
}
if (!$match_parts) {
if ($mul_words) {
$db_query->having('COUNT(DISTINCT t.word) >= ' . $var, array($var => $subs));
} else {
$db_query->having('COUNT(t.word) >= ' . $var, array($var => $subs));
}
} else {
foreach ($keyword_hits as $alias) {
$db_query->having("SUM({$alias}) >= 1");
}
}
}
}
if ($negated) {
if (!isset($db_query) || $conj == 'OR') {
if (isset($db_query)) {
// We are in a rather bizarre case where the keys are something like
// "a OR (NOT b)".
$old_query = $db_query;
}
// We use this table because all items should be contained exactly once.
$db_info = $this->getIndexDbInfo($index);
$db_query = $this->database->select($db_info['index_table'], 't');
$db_query->addField('t', 'item_id', 'item_id');
if (!$neg) {
$db_query->addExpression(':score', 'score', array(':score' => self::SCORE_MULTIPLIER));
$db_query->distinct();
}
}
if ($conj == 'AND') {
foreach ($negated as $k) {
$db_query->condition('t.item_id', $this->createKeysQuery($k, $fields, $all_fields, $index), 'NOT IN');
}
} else {
$or = new Condition('OR');
foreach ($negated as $k) {
$or->condition('t.item_id', $this->createKeysQuery($k, $fields, $all_fields, $index), 'NOT IN');
}
if (isset($old_query)) {
$or->condition('t.item_id', $old_query, 'NOT IN');
}
$db_query->condition($or);
}
}
if ($neg_nested) {
$db_query = $this->database->select($db_query, 't')->fields('t', array('item_id'));
}
return $db_query;
}