BiFace_Server_Lite/vendor/yiisoft/yii2-mongodb/src/QueryBuilder.php
2020-03-27 10:13:51 +07:00

916 lines
31 KiB
PHP

<?php
/**
* @link http://www.yiiframework.com/
* @copyright Copyright (c) 2008 Yii Software LLC
* @license http://www.yiiframework.com/license/
*/
namespace yii\mongodb;
use MongoDB\BSON\Javascript;
use MongoDB\BSON\ObjectID;
use MongoDB\BSON\Regex;
use MongoDB\Driver\Exception\InvalidArgumentException;
use yii\base\InvalidParamException;
use yii\base\BaseObject;
use yii\helpers\ArrayHelper;
/**
* QueryBuilder builds a MongoDB command statements.
* It is used by [[Command]] for particular commands and queries composition.
*
* MongoDB uses JSON format to specify query conditions with quite specific syntax.
* However [[buildCondition()]] method provides the ability of "translating" common condition format used "yii\db\*"
* into MongoDB condition.
* For example:
*
* ```php
* $condition = [
* [
* 'OR',
* ['AND', ['first_name' => 'John'], ['last_name' => 'Smith']],
* ['status' => [1, 2, 3]]
* ],
* ];
* print_r(Yii::$app->mongodb->getQueryBuilder()->buildCondition($condition));
* // outputs :
* [
* '$or' => [
* [
* 'first_name' => 'John',
* 'last_name' => 'John',
* ],
* [
* 'status' => ['$in' => [1, 2, 3]],
* ]
* ]
* ]
* ```
*
* Note: condition values for the key '_id' will be automatically cast to [[\MongoDB\BSON\ObjectID]] instance,
* even if they are plain strings. However, if you have other columns, containing [[\MongoDB\BSON\ObjectID]], you
* should take care of possible typecast on your own.
*
* @author Paul Klimov <klimov.paul@gmail.com>
* @since 2.1
*/
class QueryBuilder extends BaseObject
{
/**
* @var Connection the MongoDB connection.
*/
public $db;
/**
* Constructor.
* @param Connection $connection the database connection.
* @param array $config name-value pairs that will be used to initialize the object properties
*/
public function __construct($connection, $config = [])
{
$this->db = $connection;
parent::__construct($config);
}
// Commands :
/**
* Generates 'create collection' command.
* https://docs.mongodb.com/manual/reference/method/db.createCollection/
* @param string $collectionName collection name.
* @param array $options collection options in format: "name" => "value"
* @return array command document.
*/
public function createCollection($collectionName, array $options = [])
{
$document = array_merge(['create' => $collectionName], $options);
if (isset($document['indexOptionDefaults'])) {
$document['indexOptionDefaults'] = (object) $document['indexOptionDefaults'];
}
if (isset($document['storageEngine'])) {
$document['storageEngine'] = (object) $document['storageEngine'];
}
if (isset($document['validator'])) {
$document['validator'] = (object) $document['validator'];
}
return $document;
}
/**
* Generates drop database command.
* https://docs.mongodb.com/manual/reference/method/db.dropDatabase/
* @return array command document.
*/
public function dropDatabase()
{
return ['dropDatabase' => 1];
}
/**
* Generates drop collection command.
* https://docs.mongodb.com/manual/reference/method/db.collection.drop/
* @param string $collectionName name of the collection to be dropped.
* @return array command document.
*/
public function dropCollection($collectionName)
{
return ['drop' => $collectionName];
}
/**
* Generates create indexes command.
* @see https://docs.mongodb.com/manual/reference/method/db.collection.createIndex/
* @param string|null $databaseName database name.
* @param string $collectionName collection name.
* @param array[] $indexes indexes specification. Each specification should be an array in format: optionName => value
* The main options are:
*
* - keys: array, column names with sort order, to be indexed. This option is mandatory.
* - unique: bool, whether to create unique index.
* - name: string, the name of the index, if not set it will be generated automatically.
* - background: bool, whether to bind index in the background.
* - sparse: bool, whether index should reference only documents with the specified field.
*
* See [[https://docs.mongodb.com/manual/reference/method/db.collection.createIndex/#options-for-all-index-types]]
* for the full list of options.
* @return array command document.
*/
public function createIndexes($databaseName, $collectionName, $indexes)
{
$normalizedIndexes = [];
foreach ($indexes as $index) {
if (!isset($index['key'])) {
throw new InvalidParamException('"key" is required for index specification');
}
$index['key'] = $this->buildSortFields($index['key']);
if (!isset($index['ns'])) {
if ($databaseName === null) {
$databaseName = $this->db->getDefaultDatabaseName();
}
$index['ns'] = $databaseName . '.' . $collectionName;
}
if (!isset($index['name'])) {
$index['name'] = $this->generateIndexName($index['key']);
}
$normalizedIndexes[] = $index;
}
return [
'createIndexes' => $collectionName,
'indexes' => $normalizedIndexes,
];
}
/**
* Generates index name for the given column orders.
* Columns should be normalized using [[buildSortFields()]] before being passed to this method.
* @param array $columns columns with sort order.
* @return string index name.
*/
public function generateIndexName($columns)
{
$parts = [];
foreach ($columns as $column => $order) {
$parts[] = $column . '_' . $order;
}
return implode('_', $parts);
}
/**
* Generates drop indexes command.
* @param string $collectionName collection name
* @param string $index index name or pattern, use `*` in order to drop all indexes.
* @return array command document.
*/
public function dropIndexes($collectionName, $index)
{
return [
'dropIndexes' => $collectionName,
'index' => $index,
];
}
/**
* Generates list indexes command.
* @param string $collectionName collection name
* @param array $options command options.
* Available options are:
*
* - maxTimeMS: int, max execution time in ms.
*
* @return array command document.
*/
public function listIndexes($collectionName, $options = [])
{
return array_merge(['listIndexes' => $collectionName], $options);
}
/**
* Generates count command
* @param string $collectionName
* @param array $condition
* @param array $options
* @return array command document.
*/
public function count($collectionName, $condition = [], $options = [])
{
$document = ['count' => $collectionName];
if (!empty($condition)) {
$document['query'] = (object) $this->buildCondition($condition);
}
return array_merge($document, $options);
}
/**
* Generates 'find and modify' command.
* @param string $collectionName collection name
* @param array $condition filter condition
* @param array $update update criteria
* @param array $options list of options in format: optionName => optionValue.
* @return array command document.
*/
public function findAndModify($collectionName, $condition = [], $update = [], $options = [])
{
$document = array_merge(['findAndModify' => $collectionName], $options);
if (!empty($condition)) {
$options['query'] = $this->buildCondition($condition);
}
if (!empty($update)) {
$options['update'] = $update;
}
if (isset($options['fields'])) {
$options['fields'] = $this->buildSelectFields($options['fields']);
}
if (isset($options['sort'])) {
$options['sort'] = $this->buildSortFields($options['sort']);
}
foreach (['fields', 'query', 'sort', 'update'] as $name) {
if (isset($options[$name])) {
$document[$name] = (object) $options[$name];
}
}
return $document;
}
/**
* Generates 'distinct' command.
* @param string $collectionName collection name.
* @param string $fieldName target field name.
* @param array $condition filter condition
* @param array $options list of options in format: optionName => optionValue.
* @return array command document.
*/
public function distinct($collectionName, $fieldName, $condition = [], $options = [])
{
$document = array_merge(
[
'distinct' => $collectionName,
'key' => $fieldName,
],
$options
);
if (!empty($condition)) {
$document['query'] = $this->buildCondition($condition);
}
return $document;
}
/**
* Generates 'group' command.
* @param string $collectionName
* @@param mixed $keys fields to group by. If an array or non-code object is passed,
* it will be the key used to group results. If instance of [[Javascript]] passed,
* it will be treated as a function that returns the key to group by.
* @param array $initial Initial value of the aggregation counter object.
* @param Javascript|string $reduce function that takes two arguments (the current
* document and the aggregation to this point) and does the aggregation.
* Argument will be automatically cast to [[Javascript]].
* @param array $options optional parameters to the group command. Valid options include:
* - condition - criteria for including a document in the aggregation.
* - finalize - function called once per unique key that takes the final output of the reduce function.
* @return array command document.
*/
public function group($collectionName, $keys, $initial, $reduce, $options = [])
{
if (!($reduce instanceof Javascript)) {
$reduce = new Javascript((string) $reduce);
}
if (isset($options['condition'])) {
$options['cond'] = $this->buildCondition($options['condition']);
unset($options['condition']);
}
if (isset($options['finalize'])) {
if (!($options['finalize'] instanceof Javascript)) {
$options['finalize'] = new Javascript((string) $options['finalize']);
}
}
if (isset($options['keyf'])) {
$options['$keyf'] = $options['keyf'];
unset($options['keyf']);
}
if (isset($options['$keyf'])) {
if (!($options['$keyf'] instanceof Javascript)) {
$options['$keyf'] = new Javascript((string) $options['$keyf']);
}
}
$document = [
'group' => array_merge(
[
'ns' => $collectionName,
'key' => $keys,
'initial' => $initial,
'$reduce' => $reduce,
],
$options
)
];
return $document;
}
/**
* Generates 'map-reduce' command.
* @see https://docs.mongodb.com/manual/core/map-reduce/
* @param string $collectionName collection name.
* @param \MongoDB\BSON\Javascript|string $map function, which emits map data from collection.
* Argument will be automatically cast to [[\MongoDB\BSON\Javascript]].
* @param \MongoDB\BSON\Javascript|string $reduce function that takes two arguments (the map key
* and the map values) and does the aggregation.
* Argument will be automatically cast to [[\MongoDB\BSON\Javascript]].
* @param string|array $out output collection name. It could be a string for simple output
* ('outputCollection'), or an array for parametrized output (['merge' => 'outputCollection']).
* You can pass ['inline' => true] to fetch the result at once without temporary collection usage.
* @param array $condition filter condition for including a document in the aggregation.
* @param array $options additional optional parameters to the mapReduce command. Valid options include:
*
* - sort: array, key to sort the input documents. The sort key must be in an existing index for this collection.
* - limit: int, the maximum number of documents to return in the collection.
* - finalize: \MongoDB\BSON\Javascript|string, function, which follows the reduce method and modifies the output.
* - scope: array, specifies global variables that are accessible in the map, reduce and finalize functions.
* - jsMode: bool, specifies whether to convert intermediate data into BSON format between the execution of the map and reduce functions.
* - verbose: bool, specifies whether to include the timing information in the result information.
*
* @return array command document.
*/
public function mapReduce($collectionName, $map, $reduce, $out, $condition = [], $options = [])
{
if (!($map instanceof Javascript)) {
$map = new Javascript((string) $map);
}
if (!($reduce instanceof Javascript)) {
$reduce = new Javascript((string) $reduce);
}
$document = [
'mapReduce' => $collectionName,
'map' => $map,
'reduce' => $reduce,
'out' => $out
];
if (!empty($condition)) {
$document['query'] = $this->buildCondition($condition);
}
if (!empty($options)) {
$document = array_merge($document, $options);
}
return $document;
}
/**
* Generates 'aggregate' command.
* @param string $collectionName collection name
* @param array $pipelines list of pipeline operators.
* @param array $options optional parameters.
* @return array command document.
*/
public function aggregate($collectionName, $pipelines, $options = [])
{
foreach ($pipelines as $key => $pipeline) {
if (isset($pipeline['$match'])) {
$pipelines[$key]['$match'] = $this->buildCondition($pipeline['$match']);
}
}
$document = array_merge(
[
'aggregate' => $collectionName,
'pipeline' => $pipelines,
'allowDiskUse' => false,
],
$options
);
return $document;
}
/**
* Generates 'explain' command.
* @param string $collectionName collection name.
* @param array $query query options.
* @return array command document.
*/
public function explain($collectionName, $query)
{
$query = array_merge(
['find' => $collectionName],
$query
);
if (isset($query['filter'])) {
$query['filter'] = (object) $this->buildCondition($query['filter']);
}
if (isset($query['projection'])) {
$query['projection'] = $this->buildSelectFields($query['projection']);
}
if (isset($query['sort'])) {
$query['sort'] = $this->buildSortFields($query['sort']);
}
return [
'explain' => $query,
];
}
/**
* Generates 'listDatabases' command.
* @param array $condition filter condition.
* @param array $options command options.
* @return array command document.
*/
public function listDatabases($condition = [], $options = [])
{
$document = array_merge(['listDatabases' => 1], $options);
if (!empty($condition)) {
$document['filter'] = (object)$this->buildCondition($condition);
}
return $document;
}
/**
* Generates 'listCollections' command.
* @param array $condition filter condition.
* @param array $options command options.
* @return array command document.
*/
public function listCollections($condition = [], $options = [])
{
$document = array_merge(['listCollections' => 1], $options);
if (!empty($condition)) {
$document['filter'] = (object)$this->buildCondition($condition);
}
return $document;
}
// Service :
/**
* Normalizes fields list for the MongoDB select composition.
* @param array|string $fields raw fields.
* @return array normalized select fields.
*/
public function buildSelectFields($fields)
{
$selectFields = [];
foreach ((array)$fields as $key => $value) {
if (is_int($key)) {
$selectFields[$value] = true;
} else {
$selectFields[$key] = is_scalar($value) ? (bool)$value : $value;
}
}
return $selectFields;
}
/**
* Normalizes fields list for the MongoDB sort composition.
* @param array|string $fields raw fields.
* @return array normalized sort fields.
*/
public function buildSortFields($fields)
{
$sortFields = [];
foreach ((array)$fields as $key => $value) {
if (is_int($key)) {
$sortFields[$value] = +1;
} else {
if ($value === SORT_ASC) {
$value = +1;
} elseif ($value === SORT_DESC) {
$value = -1;
}
$sortFields[$key] = $value;
}
}
return $sortFields;
}
/**
* Converts "\yii\db\*" quick condition keyword into actual Mongo condition keyword.
* @param string $key raw condition key.
* @return string actual key.
*/
protected function normalizeConditionKeyword($key)
{
static $map = [
'AND' => '$and',
'OR' => '$or',
'IN' => '$in',
'NOT IN' => '$nin',
];
$matchKey = strtoupper($key);
if (array_key_exists($matchKey, $map)) {
return $map[$matchKey];
}
return $key;
}
/**
* Converts given value into [[ObjectID]] instance.
* If array given, each element of it will be processed.
* @param mixed $rawId raw id(s).
* @return array|ObjectID normalized id(s).
*/
protected function ensureMongoId($rawId)
{
if (is_array($rawId)) {
$result = [];
foreach ($rawId as $key => $value) {
$result[$key] = $this->ensureMongoId($value);
}
return $result;
} elseif (is_object($rawId)) {
if ($rawId instanceof ObjectID) {
return $rawId;
} else {
$rawId = (string) $rawId;
}
}
try {
$mongoId = new ObjectID($rawId);
} catch (InvalidArgumentException $e) {
// invalid id format
$mongoId = $rawId;
}
return $mongoId;
}
/**
* Parses the condition specification and generates the corresponding Mongo condition.
* @param array $condition the condition specification. Please refer to [[Query::where()]]
* on how to specify a condition.
* @return array the generated Mongo condition
* @throws InvalidParamException if the condition is in bad format
*/
public function buildCondition($condition)
{
static $builders = [
'NOT' => 'buildNotCondition',
'AND' => 'buildAndCondition',
'OR' => 'buildOrCondition',
'BETWEEN' => 'buildBetweenCondition',
'NOT BETWEEN' => 'buildBetweenCondition',
'IN' => 'buildInCondition',
'NOT IN' => 'buildInCondition',
'REGEX' => 'buildRegexCondition',
'LIKE' => 'buildLikeCondition',
];
if (!is_array($condition)) {
throw new InvalidParamException('Condition should be an array.');
} elseif (empty($condition)) {
return [];
}
if (isset($condition[0])) { // operator format: operator, operand 1, operand 2, ...
$operator = strtoupper($condition[0]);
if (isset($builders[$operator])) {
$method = $builders[$operator];
} else {
$operator = $condition[0];
$method = 'buildSimpleCondition';
}
array_shift($condition);
return $this->$method($operator, $condition);
}
// hash format: 'column1' => 'value1', 'column2' => 'value2', ...
return $this->buildHashCondition($condition);
}
/**
* Creates a condition based on column-value pairs.
* @param array $condition the condition specification.
* @return array the generated Mongo condition.
*/
public function buildHashCondition($condition)
{
$result = [];
foreach ($condition as $name => $value) {
if (strncmp('$', $name, 1) === 0) {
// Native Mongo condition:
$result[$name] = $value;
} else {
if (is_array($value)) {
if (ArrayHelper::isIndexed($value)) {
// Quick IN condition:
$result = array_merge($result, $this->buildInCondition('IN', [$name, $value]));
} else {
// Mongo complex condition:
$result[$name] = $value;
}
} else {
// Direct match:
if ($name == '_id') {
$value = $this->ensureMongoId($value);
}
$result[$name] = $value;
}
}
}
return $result;
}
/**
* Composes `NOT` condition.
* @param string $operator the operator to use for connecting the given operands
* @param array $operands the Mongo conditions to connect.
* @return array the generated Mongo condition.
* @throws InvalidParamException if wrong number of operands have been given.
*/
public function buildNotCondition($operator, $operands)
{
if (count($operands) !== 2) {
throw new InvalidParamException("Operator '$operator' requires two operands.");
}
list($name, $value) = $operands;
$result = [];
if (is_array($value)) {
$result[$name] = ['$not' => $this->buildCondition($value)];
} else {
if ($name == '_id') {
$value = $this->ensureMongoId($value);
}
$result[$name] = ['$ne' => $value];
}
return $result;
}
/**
* Connects two or more conditions with the `AND` operator.
* @param string $operator the operator to use for connecting the given operands
* @param array $operands the Mongo conditions to connect.
* @return array the generated Mongo condition.
*/
public function buildAndCondition($operator, $operands)
{
$operator = $this->normalizeConditionKeyword($operator);
$parts = [];
foreach ($operands as $operand) {
$parts[] = $this->buildCondition($operand);
}
return [$operator => $parts];
}
/**
* Connects two or more conditions with the `OR` operator.
* @param string $operator the operator to use for connecting the given operands
* @param array $operands the Mongo conditions to connect.
* @return array the generated Mongo condition.
*/
public function buildOrCondition($operator, $operands)
{
$operator = $this->normalizeConditionKeyword($operator);
$parts = [];
foreach ($operands as $operand) {
$parts[] = $this->buildCondition($operand);
}
return [$operator => $parts];
}
/**
* Creates an Mongo condition, which emulates the `BETWEEN` operator.
* @param string $operator the operator to use
* @param array $operands the first operand is the column name. The second and third operands
* describe the interval that column value should be in.
* @return array the generated Mongo condition.
* @throws InvalidParamException if wrong number of operands have been given.
*/
public function buildBetweenCondition($operator, $operands)
{
if (!isset($operands[0], $operands[1], $operands[2])) {
throw new InvalidParamException("Operator '$operator' requires three operands.");
}
list($column, $value1, $value2) = $operands;
if (strncmp('NOT', $operator, 3) === 0) {
return [
$column => [
'$lt' => $value1,
'$gt' => $value2,
]
];
}
return [
$column => [
'$gte' => $value1,
'$lte' => $value2,
]
];
}
/**
* Creates an Mongo condition with the `IN` operator.
* @param string $operator the operator to use (e.g. `IN` or `NOT IN`)
* @param array $operands the first operand is the column name. If it is an array
* a composite IN condition will be generated.
* The second operand is an array of values that column value should be among.
* @return array the generated Mongo condition.
* @throws InvalidParamException if wrong number of operands have been given.
*/
public function buildInCondition($operator, $operands)
{
if (!isset($operands[0], $operands[1])) {
throw new InvalidParamException("Operator '$operator' requires two operands.");
}
list($column, $values) = $operands;
$values = (array) $values;
$operator = $this->normalizeConditionKeyword($operator);
if (!is_array($column)) {
$columns = [$column];
$values = [$column => $values];
} elseif (count($column) > 1) {
return $this->buildCompositeInCondition($operator, $column, $values);
} else {
$columns = $column;
$values = [$column[0] => $values];
}
$result = [];
foreach ($columns as $column) {
if ($column == '_id') {
$inValues = $this->ensureMongoId($values[$column]);
} else {
$inValues = $values[$column];
}
$inValues = array_values($inValues);
if (count($inValues) === 1 && $operator === '$in') {
$result[$column] = $inValues[0];
} else {
$result[$column][$operator] = $inValues;
}
}
return $result;
}
/**
* @param string $operator MongoDB the operator to use (`$in` OR `$nin`)
* @param array $columns list of compare columns
* @param array $values compare values in format: columnName => [values]
* @return array the generated Mongo condition.
*/
private function buildCompositeInCondition($operator, $columns, $values)
{
$result = [];
$inValues = [];
foreach ($values as $columnValues) {
foreach ($columnValues as $column => $value) {
if ($column == '_id') {
$value = $this->ensureMongoId($value);
}
$inValues[$column][] = $value;
}
}
foreach ($columns as $column) {
$columnInValues = array_values($inValues[$column]);
if (count($columnInValues) === 1 && $operator === '$in') {
$result[$column] = $columnInValues[0];
} else {
$result[$column][$operator] = $columnInValues;
}
}
return $result;
}
/**
* Creates a Mongo regular expression condition.
* @param string $operator the operator to use
* @param array $operands the first operand is the column name.
* The second operand is a single value that column value should be compared with.
* @return array the generated Mongo condition.
* @throws InvalidParamException if wrong number of operands have been given.
*/
public function buildRegexCondition($operator, $operands)
{
if (!isset($operands[0], $operands[1])) {
throw new InvalidParamException("Operator '$operator' requires two operands.");
}
list($column, $value) = $operands;
if (!($value instanceof Regex)) {
if (preg_match('~\/(.+)\/(.*)~', $value, $matches)) {
$value = new Regex($matches[1], $matches[2]);
} else {
$value = new Regex($value, '');
}
}
return [$column => $value];
}
/**
* Creates a Mongo condition, which emulates the `LIKE` operator.
* @param string $operator the operator to use
* @param array $operands the first operand is the column name.
* The second operand is a single value that column value should be compared with.
* @return array the generated Mongo condition.
* @throws InvalidParamException if wrong number of operands have been given.
*/
public function buildLikeCondition($operator, $operands)
{
if (!isset($operands[0], $operands[1])) {
throw new InvalidParamException("Operator '$operator' requires two operands.");
}
list($column, $value) = $operands;
if (!($value instanceof Regex)) {
$value = new Regex(preg_quote($value), 'i');
}
return [$column => $value];
}
/**
* Creates an Mongo condition like `{$operator:{field:value}}`.
* @param string $operator the operator to use. Besides regular MongoDB operators, aliases like `>`, `<=`,
* and so on, can be used here.
* @param array $operands the first operand is the column name.
* The second operand is a single value that column value should be compared with.
* @return string the generated Mongo condition.
* @throws InvalidParamException if wrong number of operands have been given.
*/
public function buildSimpleCondition($operator, $operands)
{
if (count($operands) !== 2) {
throw new InvalidParamException("Operator '$operator' requires two operands.");
}
list($column, $value) = $operands;
if (strncmp('$', $operator, 1) !== 0) {
static $operatorMap = [
'>' => '$gt',
'<' => '$lt',
'>=' => '$gte',
'<=' => '$lte',
'!=' => '$ne',
'<>' => '$ne',
'=' => '$eq',
'==' => '$eq',
];
if (isset($operatorMap[$operator])) {
$operator = $operatorMap[$operator];
} else {
throw new InvalidParamException("Unsupported operator '{$operator}'");
}
}
return [$column => [$operator => $value]];
}
}