debug('executeQuery called', ['query' => $query, 'document' => $document, 'options' => $options]); } if (!is_array($query)) { return (bool)$query; } return self::_executeQuery($query, $document, $options); } /** * Internal execute query * * This expects an array from the query and has an additional logical operator (for the root query object the logical operator is always $and so this is not required) * * @throws Exception */ private static function _executeQuery(array $query, array &$document, array $options = [], string $logicalOperator = '$and'): bool { if ($logicalOperator !== '$and' && (!count($query) || !isset($query[0]))) { throw new Exception($logicalOperator . ' requires nonempty array'); } if ($options['_debug'] && $options['_shouldLog']) { $options['logger']->debug('_executeQuery called', ['query' => $query, 'document' => $document, 'logicalOperator' => $logicalOperator]); } // for the purpose of querying documents, we are going to specify that an indexed array is an array which // only contains numeric keys, is sequential, the first key is zero, and not empty. This will allow us // to detect an array of key->vals that have numeric IDs vs an array of queries (where keys were not specified) $queryIsIndexedArray = !empty($query) && array_is_list($query); foreach ($query as $k => $q) { $pass = true; if (is_string($k) && substr($k, 0, 1) === '$') { // key is an operator at this level, except $not, which can be at any level if ($k === '$not') { $pass = !self::_executeQuery($q, $document, $options); } else { $pass = self::_executeQuery($q, $document, $options, $k); } } elseif ($logicalOperator === '$and') { // special case for $and if ($queryIsIndexedArray) { // $q is an array of query objects $pass = self::_executeQuery($q, $document, $options); } elseif (is_array($q)) { // query is array, run all queries on field. All queries must match. e.g { 'age': { $gt: 24, $lt: 52 } } $pass = self::_executeQueryOnElement($q, $k, $document, $options); } else { // key value means equality $pass = self::_executeOperatorOnElement('$e', $q, $k, $document, $options); } } else { // $q is array of query objects e.g '$or' => [{'fullName' => 'Nick'}] $pass = self::_executeQuery($q, $document, $options, '$and'); } switch ($logicalOperator) { case '$and': // if any fail, query fails if (!$pass) { return false; } break; case '$or': // if one succeeds, query succeeds if ($pass) { return true; } break; case '$nor': // if one succeeds, query fails if ($pass) { return false; } break; default: if ($options['_shouldLog']) { $options['logger']->warning('_executeQuery could not find logical operator', ['query' => $query, 'document' => $document, 'logicalOperator' => $logicalOperator]); } return false; } } switch ($logicalOperator) { case '$and': // all succeeded, query succeeds return true; case '$or': // all failed, query fails return false; case '$nor': // all failed, query succeeded return true; default: if ($options['_shouldLog']) { $options['logger']->warning('_executeQuery could not find logical operator', ['query' => $query, 'document' => $document, 'logicalOperator' => $logicalOperator]); } return false; } } /** * Execute a query object on an element * * @throws Exception */ private static function _executeQueryOnElement(array $query, string $element, array &$document, array $options = []): bool { if ($options['_debug'] && $options['_shouldLog']) { $options['logger']->debug('_executeQueryOnElement called', ['query' => $query, 'element' => $element, 'document' => $document]); } // iterate through query operators foreach ($query as $op => $opVal) { if (!self::_executeOperatorOnElement($op, $opVal, $element, $document, $options)) { return false; } } return true; } /** * Check if an operator is equal to a value * * Equality includes direct equality, regular expression match, and checking if the operator value is one of the values in an array value * * @param mixed $v * @param mixed $operatorValue */ private static function _isEqual($v, $operatorValue): bool { if (is_array($v) && is_array($operatorValue)) { return $v == $operatorValue; } if (is_array($v)) { return in_array($operatorValue, $v); } if (is_string($operatorValue) && preg_match('/^\/(.*?)\/([a-z]*)$/i', $operatorValue, $matches)) { return (bool)preg_match('/' . $matches[1] . '/' . $matches[2], $v); } return $operatorValue === $v; } /** * Execute a Mongo Operator on an element * * @param string $operator The operator to perform * @param mixed $operatorValue The value to provide the operator * @param string $element The target element. Can be an object path eg price.shoes * @param array $document The document in which to find the element * @param array $options Options * @throws Exception Exceptions on invalid operators, invalid unknown operator callback, and invalid operator values */ private static function _executeOperatorOnElement(string $operator, $operatorValue, string $element, array &$document, array $options = []): bool { if ($options['_debug'] && $options['_shouldLog']) { $options['logger']->debug('_executeOperatorOnElement called', ['operator' => $operator, 'operatorValue' => $operatorValue, 'element' => $element, 'document' => $document]); } if ($operator === '$not') { return !self::_executeQueryOnElement($operatorValue, $element, $document, $options); } $elementSpecifier = explode('.', $element); $v = & $document; $exists = true; foreach ($elementSpecifier as $index => $es) { if (empty($v)) { $exists = false; break; } if (isset($v[0])) { // value from document is an array, so we need to iterate through array and test the query on all elements of the array // if any elements match, then return true $newSpecifier = implode('.', array_slice($elementSpecifier, $index)); foreach ($v as $item) { if (self::_executeOperatorOnElement($operator, $operatorValue, $newSpecifier, $item, $options)) { return true; } } return false; } if (isset($v[$es])) { $v = & $v[$es]; } else { $exists = false; break; } } switch ($operator) { case '$all': if (!$exists) { return false; } if (!is_array($operatorValue)) { throw new Exception('$all requires array'); } if (count($operatorValue) === 0) { return false; } if (!is_array($v)) { if (count($operatorValue) === 1) { return $v === $operatorValue[0]; } return false; } return count(array_intersect($v, $operatorValue)) === count($operatorValue); case '$e': if (!$exists) { return false; } return self::_isEqual($v, $operatorValue); case '$in': if (!$exists) { return false; } if (!is_array($operatorValue)) { throw new Exception('$in requires array'); } if (count($operatorValue) === 0) { return false; } if (is_array($v)) { return count(array_intersect($v, $operatorValue)) > 0; } return in_array($v, $operatorValue); case '$lt': return $exists && $v < $operatorValue; case '$lte': return $exists && $v <= $operatorValue; case '$gt': return $exists && $v > $operatorValue; case '$gte': return $exists && $v >= $operatorValue; case '$ne': return (!$exists && $operatorValue !== null) || ($exists && !self::_isEqual($v, $operatorValue)); case '$nin': if (!$exists) { return true; } if (!is_array($operatorValue)) { throw new Exception('$nin requires array'); } if (count($operatorValue) === 0) { return true; } if (is_array($v)) { return count(array_intersect($v, $operatorValue)) === 0; } return !in_array($v, $operatorValue); case '$exists': return ($operatorValue && $exists) || (!$operatorValue && !$exists); case '$mod': if (!$exists) { return false; } if (!is_array($operatorValue)) { throw new Exception('$mod requires array'); } if (count($operatorValue) !== 2) { throw new Exception('$mod requires two parameters in array: divisor and remainder'); } return $v % $operatorValue[0] === $operatorValue[1]; default: if (empty($options['unknownOperatorCallback']) || !is_callable($options['unknownOperatorCallback'])) { throw new Exception('Operator ' . $operator . ' is unknown'); } $res = call_user_func($options['unknownOperatorCallback'], $operator, $operatorValue, $element, $document); if ($res === null) { throw new Exception('Operator ' . $operator . ' is unknown'); } if (!is_bool($res)) { throw new Exception('Return value of unknownOperatorCallback must be boolean, actual value ' . $res); } return $res; } throw new Exception('Didn\'t return in switch'); } /** * Get the fields this query depends on * * @param array query The query to analyse * @return array An array of fields this query depends on */ public static function getDependentFields(array $query) { $fields = []; foreach ($query as $k => $v) { if (is_array($v)) { $fields = array_merge($fields, static::getDependentFields($v)); } if (is_int($k) || $k[0] === '$') { continue; } $fields[] = $k; } return array_unique($fields); } }