diff --git a/ActiveQuery.php b/ActiveQuery.php new file mode 100644 index 0000000..963e61d --- /dev/null +++ b/ActiveQuery.php @@ -0,0 +1,166 @@ +modelClass = $modelClass; + parent::__construct($config); + } + + protected function buildQuery($query = null, $params = []) + { + if ($this->primaryModel !== null) { + // lazy loading + if ($this->via instanceof self) { + // via pivot collection + $viaModels = $this->via->findPivotRows([$this->primaryModel]); + $this->filterByModels($viaModels); + } elseif (is_array($this->via)) { + // via relation + /* @var $viaQuery ActiveQuery */ + list($viaName, $viaQuery) = $this->via; + if ($viaQuery->multiple) { + $viaModels = $viaQuery->all(); + $this->primaryModel->populateRelation($viaName, $viaModels); + } else { + $model = $viaQuery->one(); + $this->primaryModel->populateRelation($viaName, $model); + $viaModels = $model === null ? [] : [$model]; + } + $this->filterByModels($viaModels); + } else { + $this->filterByModels([$this->primaryModel]); + } + } + + return parent::buildQuery($query, $params); + } + + private function createModels($rows) + { + $models = []; + if ($this->asArray) { + array_walk( + $rows, + function (&$doc) { + if ($doc instanceof Document) { + $doc = $doc->getAll(); + } + } + ); + if ($this->indexBy === null) { + return $rows; + } + foreach ($rows as $row) { + if (is_string($this->indexBy)) { + $key = $row[$this->indexBy]; + } else { + $key = call_user_func($this->indexBy, $row); + } + $models[$key] = $row; + } + } else { + /* @var $class ActiveRecord */ + $class = $this->modelClass; + if ($this->indexBy === null) { + foreach ($rows as $row) { + $model = $class::instantiate($row); + $class::populateRecord($model, $row); + $model->setIsNewRecord(false); + $models[] = $model; + } + } else { + foreach ($rows as $row) { + $model = $class::instantiate($row); + $class::populateRecord($model, $row); + $model->setIsNewRecord(false); + if (is_string($this->indexBy)) { + $key = $model->{$this->indexBy}; + } else { + $key = call_user_func($this->indexBy, $model); + } + $models[$key] = $model; + } + } + } + + return $models; + } + + private function prefixKeyColumns($attributes) + { + if ($this instanceof ActiveQuery) { + /* @var $modelClass ActiveRecord */ + $modelClass = $this->modelClass; + foreach ($attributes as $i => $attribute) { + $attributes[$i] = "{$modelClass::collectionName()}.$attribute"; + } + } + return $attributes; + } + + public function all($db = null) + { + $statement = $this->createCommand(); + $cursor = $statement->execute(); + $rows = $cursor->getAll(); + if (!empty($rows)) { + $models = $this->createModels($rows); + if (!empty($this->with)) { + $this->findWith($this->with, $models); + } + if (!$this->asArray) { + foreach ($models as $model) { + $model->afterFind(); + } + } + + return $models; + } else { + return []; + } + } + + public function one($db = null) + { + $row = parent::one($db); + if ($row !== false) { + if ($this->asArray) { + $model = $row; + } else { + /* @var $class ActiveRecord */ + $class = $this->modelClass; + $model = $class::instantiate($row); + $class::populateRecord($model, $row); + $model->setIsNewRecord(false); + } + if (!empty($this->with)) { + $models = [$model]; + $this->findWith($this->with, $models); + $model = $models[0]; + } + if (!$this->asArray) { + $model->afterFind(); + } + + return $model; + } else { + return null; + } + } +} diff --git a/ActiveRecord.php b/ActiveRecord.php new file mode 100644 index 0000000..f52c3a5 --- /dev/null +++ b/ActiveRecord.php @@ -0,0 +1,440 @@ +document = new Document(); + + parent::__construct($config); + } + + public function mergeAttribute($name, $value) + { + $newValue = $this->getAttribute($name); + if (!is_array($newValue)) { + $newValue === null ? [] : [$newValue]; + } + + if (is_array($value)) { + $this->setAttribute($name, ArrayHelper::merge($newValue, $value)); + } else { + $newValue[] = $value; + $this->setAttribute($name, $newValue); + } + } + + public static function collectionName() + { + return Inflector::camel2id(StringHelper::basename(get_called_class()), '_'); + } + + public function setAttribute($name, $value) + { + $this->document->set($name, $value); + parent::setAttribute($name, $value); + } + + /** + * Returns the primary key **name(s)** for this AR class. + * + * Note that an array should be returned even when the record only has a single primary key. + * + * For the primary key **value** see [[getPrimaryKey()]] instead. + * + * @return string[] the primary key name(s) for this AR class. + */ + public static function primaryKey() + { + return ['_key']; + } + + /** + * Creates an [[ActiveQueryInterface|ActiveQuery]] instance for query purpose. + * + * The returned [[ActiveQueryInterface|ActiveQuery]] instance can be further customized by calling + * methods defined in [[ActiveQueryInterface]] before `one()` or `all()` is called to return + * populated ActiveRecord instances. For example, + * + * ```php + * // find the customer whose ID is 1 + * $customer = Customer::find()->where(['id' => 1])->one(); + * + * // find all active customers and order them by their age: + * $customers = Customer::find() + * ->where(['status' => 1]) + * ->orderBy('age') + * ->all(); + * ``` + * + * This method is also called by [[BaseActiveRecord::hasOne()]] and [[BaseActiveRecord::hasMany()]] to + * create a relational query. + * + * You may override this method to return a customized query. For example, + * + * ```php + * class Customer extends ActiveRecord + * { + * public static function find() + * { + * // use CustomerQuery instead of the default ActiveQuery + * return new CustomerQuery(get_called_class()); + * } + * } + * ``` + * + * The following code shows how to apply a default condition for all queries: + * + * ```php + * class Customer extends ActiveRecord + * { + * public static function find() + * { + * return parent::find()->where(['deleted' => false]); + * } + * } + * + * // Use andWhere()/orWhere() to apply the default condition + * // SELECT FROM customer WHERE `deleted`=:deleted AND age>30 + * $customers = Customer::find()->andWhere('age>30')->all(); + * + * // Use where() to ignore the default condition + * // SELECT FROM customer WHERE age>30 + * $customers = Customer::find()->where('age>30')->all(); + * + * @return ActiveQueryInterface the newly created [[ActiveQueryInterface|ActiveQuery]] instance. + */ + public static function find() + { + /** @var ActiveQuery $query */ + $query = \Yii::createObject(ActiveQuery::className(), [get_called_class()]); + $query->from(static::collectionName())->select(static::collectionName()); + + return $query; + } + + /** + * @param ActiveRecord $record + * @param Document $row + */ + public static function populateRecord($record, $row) + { + $record->document = $row; + parent::populateRecord($record, $record->document->getAll()); + } + + public function attributes() + { + $class = new \ReflectionClass($this); + $names = []; + foreach ($class->getProperties(\ReflectionProperty::IS_PUBLIC) as $property) { + if (!$property->isStatic()) { + $names[] = $property->getName(); + } + } + + return $names; + } + + /** + * Inserts the record into the database using the attribute values of this record. + * + * Usage example: + * + * ```php + * $customer = new Customer; + * $customer->name = $name; + * $customer->email = $email; + * $customer->insert(); + * ``` + * + * @param boolean $runValidation whether to perform validation before saving the record. + * If the validation fails, the record will not be inserted into the database. + * @param array $attributes list of attributes that need to be saved. Defaults to null, + * meaning all attributes that are loaded from DB will be saved. + * @return boolean whether the attributes are valid and the record is inserted successfully. + */ + public function insert($runValidation = true, $attributes = null) + { + if ($runValidation && !$this->validate($attributes)) { + return false; + } + $result = $this->insertInternal($attributes); + + return $result; + } + + protected function insertInternal($attributes = null) + { + if (!$this->beforeSave(true)) { + return false; + } + $values = $this->getDirtyAttributes($attributes); + if (empty($values)) { + $currentAttributes = $this->getAttributes(); + foreach ($this->primaryKey() as $key) { + $values[$key] = isset($currentAttributes[$key]) ? $currentAttributes[$key] : null; + } + } + + $newId = static::getDb()->getDocumentHandler()->save(static::collectionName(), $values); + static::populateRecord($this, static::getDb()->getDocument(static::collectionName(), $newId)); + $this->setIsNewRecord(false); + + $changedAttributes = array_fill_keys(array_keys($values), null); + $this->setOldAttributes($this->document->getAll()); + $this->afterSave(true, $changedAttributes); + + return true; + } + + protected function updateInternal($attributes = null) + { + if (!$this->beforeSave(false)) { + return false; + } + $values = $this->getDirtyAttributes($attributes); + if (empty($values)) { + $this->afterSave(false, $values); + return 0; + } + $condition = $this->getOldPrimaryKey(true); + $lock = $this->optimisticLock(); + if ($lock !== null) { + if (!isset($values[$lock])) { + $values[$lock] = $this->$lock + 1; + } + $condition[$lock] = $this->$lock; + } + + foreach ($values as $key => $attribute) { + $this->setAttribute($key, $attribute); + } + + $rows = static::getDb()->getDocumentHandler()->updateById( + static::collectionName(), + $this->getOldAttribute('_key'), + Document::createFromArray($values) + ); + + if ($lock !== null && !$rows) { + throw new StaleObjectException('The object being updated is outdated.'); + } + + $changedAttributes = []; + foreach ($values as $name => $value) { + $changedAttributes[$name] = $this->getOldAttribute($name); + $this->setOldAttribute($name, $value); + } + $this->afterSave(false, $changedAttributes); + + return $rows; + } + + /** + * Returns the connection used by this AR class. + * @return Connection the database connection used by this AR class. + */ + public static function getDb() + { + return \Yii::$app->get('arangodb'); + } + + protected static function findByCondition($condition, $one) + { + /** @var ActiveQuery $query */ + $query = static::find(); + + if (!ArrayHelper::isAssociative($condition)) { + // query by primary key + $primaryKey = static::primaryKey(); + if (isset($primaryKey[0])) { + $collection = static::collectionName(); + $condition = ["{$collection}.{$primaryKey[0]}" => $condition]; + } else { + throw new InvalidConfigException(get_called_class() . ' must have a primary key.'); + } + } + + return $one ? $query->andWhere($condition)->one() : $query->andWhere($condition)->all(); + } + + /** + * Updates records using the provided attribute values and conditions. + * For example, to change the status to be 1 for all customers whose status is 2: + * + * ~~~ + * Customer::updateAll(['status' => 1], ['status' => '2']); + * ~~~ + * + * @param array $attributes attribute values (name-value pairs) to be saved for the record. + * Unlike [[update()]] these are not going to be validated. + * @param array $condition the condition that matches the records that should get updated. + * Please refer to [[QueryInterface::where()]] on how to specify this parameter. + * An empty condition will match all records. + * @return integer the number of rows updated + */ + public static function updateAll($attributes, $condition = []) + { + $docs = static::findAll($condition); + + $count = 0; + foreach ($docs as $doc) { + foreach ($attributes as $key => $attribute) { + $doc->setAttribute($key, $attribute); + $doc->document->set($key, $attribute); + } + if (static::getDb()->getDocumentHandler()->update($doc->document)) { + $count++; + } + } + + return $count; + } + + /** + * Deletes records using the provided conditions. + * WARNING: If you do not specify any condition, this method will delete ALL rows in the table. + * + * For example, to delete all customers whose status is 3: + * + * ~~~ + * Customer::deleteAll([status = 3]); + * ~~~ + * + * @param array $condition the condition that matches the records that should get deleted. + * Please refer to [[QueryInterface::where()]] on how to specify this parameter. + * An empty condition will match all records. + * @return integer the number of rows deleted + */ + public static function deleteAll($condition = null) + { + /** @var Document[] $docs */ + $records = static::findAll($condition); + + $count = 0; + foreach ($records as $record) { + if (static::getDb()->getDocumentHandler()->remove($record->document)) { + $count++; + } + } + + return $count; + } + + public static function truncate() + { + return static::getDb()->getCollectionHandler()->truncate(static::collectionName()); + } + + /** + * Saves the current record. + * + * This method will call [[insert()]] when [[getIsNewRecord()|isNewRecord]] is true, or [[update()]] + * when [[getIsNewRecord()|isNewRecord]] is false. + * + * For example, to save a customer record: + * + * ~~~ + * $customer = new Customer; // or $customer = Customer::findOne($id); + * $customer->name = $name; + * $customer->email = $email; + * $customer->save(); + * ~~~ + * + * @param boolean $runValidation whether to perform validation before saving the record. + * If the validation fails, the record will not be saved to database. `false` will be returned + * in this case. + * @param array $attributeNames list of attributes that need to be saved. Defaults to null, + * meaning all attributes that are loaded from DB will be saved. + * @return boolean whether the saving succeeds + */ + public function save($runValidation = true, $attributeNames = null) + { + if ($this->getIsNewRecord()) { + return $this->insert($runValidation, $attributeNames); + } else { + return $this->update($runValidation, $attributeNames) !== false; + } + } + + /** + * Deletes the record from the database. + * + * @return integer|boolean the number of rows deleted, or false if the deletion is unsuccessful for some reason. + * Note that it is possible that the number of rows deleted is 0, even though the deletion execution is successful. + */ + public function delete() + { + $result = false; + if ($this->beforeDelete()) { + $result = $this->deleteInternal(); + $this->afterDelete(); + } + + return $result; + } + + /** + * @see ActiveRecord::delete() + * @throws StaleObjectException + */ + protected function deleteInternal() + { + $condition = $this->getOldPrimaryKey(); + $lock = $this->optimisticLock(); + if ($lock !== null) { + $condition[$lock] = $this->$lock; + } + $result = static::getDb()->getDocumentHandler()->removeById(static::collectionName(), $condition); + if ($lock !== null && !$result) { + throw new StaleObjectException('The object being deleted is outdated.'); + } + $this->setOldAttributes(null); + + return $result; + } + + /** + * Returns a value indicating whether the current record is new (not saved in the database). + * @return boolean whether the record is new and should be inserted when calling [[save()]]. + */ + public function getIsNewRecord() + { + return $this->document->getIsNew(); + } + + public function setIsNewRecord($value) + { + $this->document->setIsNew($value); + } + + public function init() + { + parent::init(); + $this->setAttributes($this->defaultValues()); + } + + public function defaultValues() + { + return []; + } +} diff --git a/ArangoDbConnection.php b/ArangoDbConnection.php index 37d7d76..26c79f0 100644 --- a/ArangoDbConnection.php +++ b/ArangoDbConnection.php @@ -2,75 +2,18 @@ namespace devgroup\arangodb; -use yii\helpers\ArrayHelper; -use yii\base\Object; +use \triagens\ArangoDb\ConnectionOptions; -// set up some aliases for less typing later - -use triagens\ArangoDb\ConnectionOptions as ArangoConnectionOptions; -use triagens\ArangoDb\DocumentHandler as ArangoDocumentHandler; - -use triagens\ArangoDb\Document as ArangoDocument; -use triagens\ArangoDb\Exception as ArangoException; -use triagens\ArangoDb\ConnectException as ArangoConnectException; -use triagens\ArangoDb\ClientException as ArangoClientException; -use triagens\ArangoDb\ServerException as ArangoServerException; -use triagens\ArangoDb\UpdatePolicy as ArangoUpdatePolicy; -use triagens\ArangoDb\Statement as Statement; - -class ArangoDbConnection extends Object { - private $_connection = null; - public $connectionOptions = [ - // server endpoint to connect to - ArangoConnectionOptions::OPTION_ENDPOINT => 'tcp://127.0.0.1:8529', - // authorization type to use (currently supported: 'Basic') - ArangoConnectionOptions::OPTION_AUTH_TYPE => 'Basic', - // user for basic authorization - ArangoConnectionOptions::OPTION_AUTH_USER => 'root', - // password for basic authorization - ArangoConnectionOptions::OPTION_AUTH_PASSWD => '', - // connection persistence on server. can use either 'Close' (one-time connections) or 'Keep-Alive' (re-used connections) - ArangoConnectionOptions::OPTION_CONNECTION => 'Close', - // connect timeout in seconds - ArangoConnectionOptions::OPTION_TIMEOUT => 3, - // whether or not to reconnect when a keep-alive connection has timed out on server - ArangoConnectionOptions::OPTION_RECONNECT => true, - // optionally create new collections when inserting documents - ArangoConnectionOptions::OPTION_CREATE => true, - // optionally create new collections when inserting documents - ArangoConnectionOptions::OPTION_UPDATE_POLICY => ArangoUpdatePolicy::LAST, - ]; - - private $_collectionHandler = null; - private $_documentHandler = null; - - public function __construct($config=[]) +class ArangoDbConnection extends \triagens\ArangoDb\Connection +{ + public function json_encode_wrapper($data, $options = null) { - parent::__construct($config); + if ($this->getOption(ConnectionOptions::OPTION_CHECK_UTF8_CONFORM) === true) { + self::check_encoding($data); + } + + $response = json_encode($data, $options | JSON_FORCE_OBJECT); + + return $response; } - - public function init() - { - parent::init(); - - $this->_connection = new Connection($this->connectionOptions); - $this->_collectionHandler = new \triagens\ArangoDb\CollectionHandler($this->_connection); - $this->_documentHandler = new \triagens\ArangoDb\DocumentHandler($this->_connection); - } - - public function getDocument($collection, $id) { - return $this->documentHandler()->get($collection, $id); - } - - public function documentHandler() { - return $this->_documentHandler; - } - - public function statement($options=[]) { - return new Statement($this->_connection, $options); - } - - public function collectionHandler() { - return $this->_collectionHandler; - } -} \ No newline at end of file +} diff --git a/ArangoModel.php b/ArangoModel.php deleted file mode 100644 index 58d411c..0000000 --- a/ArangoModel.php +++ /dev/null @@ -1,109 +0,0 @@ -setDocument(Yii::$app->arango->getDocument(static::class_to_collection(get_called_class()), $id)) - ->setIsNewRecord(false); - - return $model; - } - - public function getAttributes($names=null, $except=['_id']){ - return parent::getAttributes($names, $except); - } - - /** - * @todo функция должна возвращать true/false в зависимости от результата - * Но аранга возвращает различный тип данных. Надо написать код - * - */ - public function save() - { - if ($this->_isNewRecord) { - // добавляем запись - $this->_doc = Document::createFromArray($this->getAttributes()); - - $result = intval(Yii::$app->arango->documentHandler()->add(static::class_to_collection(get_called_class()), $this->_doc)) > 0; - if ($result) { - $this->_isNewRecord = false; - } - return $result; - } else { - // патчим! - $doc_attributes = array_keys($this->_doc->getAll()); - - $attributes = $this->getAttributes(); - foreach ($attributes as $k=>$v) { - $this->_doc->set($k, $v); - unset($doc_attributes[$k]); - } - foreach ($doc_attributes as $key) { - if ($key != '_key') - unset($this->_doc->$key); - } - return Yii::$app->arango->documentHandler()->update($this->_doc); - } - } - - private static function class_to_collection($class) - { - $parts = explode("\\", $class); - return end($parts); - } - private static function id_to_int($class) - { - $parts = explode("/", $class); - return end($parts); - } - - public function setIsNewRecord($state) - { - $this->_isNewRecord = $state; - return $this; - } - - public function setDocument($doc) - { - $this->_doc = $doc; - $all = $this->_doc->getAll(); - $this->_id = $this->_doc->getInternalId(); - $this->setAttributes($all, false); - - return $this; - } - - public function delete() - { - - Yii::$app->arango->documentHandler()->deleteById( - static::class_to_collection(get_called_class()), - static::id_to_int($this->_doc->getInternalId()) - ); - } -} \ No newline at end of file diff --git a/ArangoProvider.php b/ArangoProvider.php deleted file mode 100644 index 2d33500..0000000 --- a/ArangoProvider.php +++ /dev/null @@ -1,142 +0,0 @@ -arango = Instance::ensure($this->arango, \devgroup\arangodb\ArangoDbConnection::className()); - if ($this->collection === null) { - throw new InvalidConfigException('The "collection" property must be set.'); - } - } - - /** - * @inheritdoc - */ - protected function prepareKeys($models) - { - - return array_keys($models); - - } - - /** - * @inheritdoc - */ - protected function prepareModels() - { - // $sql = $this->sql; - // $qb = $this->db->getQueryBuilder(); - // if (($sort = $this->getSort()) !== false) { - // $orderBy = $qb->buildOrderBy($sort->getOrders()); - // if (!empty($orderBy)) { - // $orderBy = substr($orderBy, 9); - // if (preg_match('/\s+order\s+by\s+[\w\s,\.]+$/i', $sql)) { - // $sql .= ', ' . $orderBy; - // } else { - // $sql .= ' ORDER BY ' . $orderBy; - // } - // } - // } - - // if (($pagination = $this->getPagination()) !== false) { - // $pagination->totalCount = $this->getTotalCount(); - // $sql .= ' ' . $qb->buildLimit($pagination->getLimit(), $pagination->getOffset()); - // } - - // return $this->db->createCommand($sql, $this->params)->queryAll(); - $statement = $this->getBaseStatement(); - - if (($pagination = $this->getPagination()) !== false) { - $pagination->totalCount = $this->getTotalCount(); - $statement->setQuery($statement->getQuery() . "\n LIMIT " . $pagination->getOffset() . ", " . $pagination->getLimit()); - } - - - $statement->setQuery($statement->getQuery()."\n RETURN a"); - $cursor = $statement->execute(); - $data = $cursor->getAll(); - $result = []; - foreach ($data as $doc) { - $item = $doc->getAll(); - foreach ($item as $k=>$v) { - if (is_array($item[$k]) || is_object($item[$k])) { - $item[$k] = json_encode($v, true); - } - } - $result[$item['_key']] = $item; - } - if (is_object($pagination)) { - $pagination->totalCount = $cursor->getFullCount(); - } - - return $result; - } - - public function getTotalCount() { - $statement = $this->getBaseStatement(); - $statement->setQuery($statement->getQuery(). "\n LIMIT 1 \n RETURN a"); - - - $cursor = $statement->execute(); - return $cursor->getFullCount(); - } - - private function getBaseStatement() { - $query = "FOR a in @@collection\n"; - - $filter = []; - $bindings = ['@collection' => $this->collection]; - $counter = 0; - foreach ($this->params as $k => $v) { - $filter[] = " a.@filter_field_$counter == @filter_value_$counter "; - $bindings["filter_field_$counter"] = $k; - $bindings["filter_value_$counter"] = $v; - $counter++; - } - if (count($filter)>0){ - $query .= "\nFILTER ".implode(" && ", $filter)."\n"; - } - - if ($this->sort) { - $query .= "\n SORT a." . $this->sort; - } - - $statement = $this->arango->statement([ - 'query' => $query, - 'count' => true, - 'bindVars' => $bindings, - 'fullCount' => true, - ]); - return $statement; - } - - /** - * @inheritdoc - */ - protected function prepareTotalCount() - { - return 0; - } -} \ No newline at end of file diff --git a/Connection.php b/Connection.php index 1868ea1..e19f7e8 100644 --- a/Connection.php +++ b/Connection.php @@ -2,18 +2,96 @@ namespace devgroup\arangodb; -use \triagens\ArangoDb\ConnectionOptions; +use triagens\ArangoDb\CollectionHandler; +use triagens\ArangoDb\ConnectionOptions; +use triagens\ArangoDb\Document; +use triagens\ArangoDb\DocumentHandler; +use triagens\ArangoDb\Statement; +use triagens\ArangoDb\UpdatePolicy; -class Connection extends \triagens\ArangoDb\Connection { - public function json_encode_wrapper($data, $options = null) +use yii\base\Object; + +class Connection extends Object +{ + private $connection = null; + + public $connectionOptions = [ + // server endpoint to connect to + ConnectionOptions::OPTION_ENDPOINT => 'tcp://127.0.0.1:8529', + // authorization type to use (currently supported: 'Basic') + ConnectionOptions::OPTION_AUTH_TYPE => 'Basic', + // user for basic authorization + ConnectionOptions::OPTION_AUTH_USER => 'root', + // password for basic authorization + ConnectionOptions::OPTION_AUTH_PASSWD => '', + // connection persistence on server. can use either 'Close' + // (one-time connections) or 'Keep-Alive' (re-used connections) + ConnectionOptions::OPTION_CONNECTION => 'Close', + // connect timeout in seconds + ConnectionOptions::OPTION_TIMEOUT => 3, + // whether or not to reconnect when a keep-alive connection has timed out on server + ConnectionOptions::OPTION_RECONNECT => true, + // optionally create new collections when inserting documents + ConnectionOptions::OPTION_CREATE => true, + // optionally create new collections when inserting documents + ConnectionOptions::OPTION_UPDATE_POLICY => UpdatePolicy::LAST, + ]; + + /** @var null|CollectionHandler $collectionHandler */ + private $collectionHandler = null; + /** @var null|DocumentHandler $documentHandler */ + private $documentHandler = null; + + public function init() { - if ($this->getOption(ConnectionOptions::OPTION_CHECK_UTF8_CONFORM) === true) { - self::check_encoding($data); - } + parent::init(); - - $response = json_encode($data, $options | JSON_FORCE_OBJECT); - - return $response; + $this->connection = new ArangoDbConnection($this->connectionOptions); + $this->collectionHandler = new CollectionHandler($this->connection); + $this->documentHandler = new DocumentHandler($this->connection); } -} \ No newline at end of file + + /** + * @return null|CollectionHandler + */ + public function getCollectionHandler() + { + return $this->collectionHandler; + } + + /** + * @param $collectionId + * @return \triagens\ArangoDb\Collection + */ + public function getCollection($collectionId) + { + return $this->getCollectionHandler()->get($collectionId); + } + + /** + * @return null|DocumentHandler + */ + public function getDocumentHandler() + { + return $this->documentHandler; + } + + /** + * @param $collectionId + * @param $documentId + * @return Document + */ + public function getDocument($collectionId, $documentId) + { + return $this->getDocumentHandler()->get($collectionId, $documentId); + } + + /** + * @param array $options + * @return Statement + */ + public function getStatement($options = []) + { + return new Statement($this->connection, $options); + } +} diff --git a/Exception.php b/Exception.php new file mode 100644 index 0000000..6e0b4ad --- /dev/null +++ b/Exception.php @@ -0,0 +1,14 @@ +db = Instance::ensure($this->db, Connection::className()); + } + + public function execute($aql, $bindValues = [], $params = []) + { + echo " > execute AQL: $aql ..."; + $time = microtime(true); + $options = [ + 'query' => $aql, + 'bindValues' => $bindValues, + ]; + $options = ArrayHelper::merge($params, $options); + $statement = $this->db->getStatement($options); + $statement->execute(); + echo " done (time: " . sprintf('%.3f', microtime(true) - $time) . "s)\n"; + } + + public function insert($collection, $columns) + { + echo " > insert into $collection ..."; + $time = microtime(true); + $this->db->getDocumentHandler()->save($collection, $columns); + echo " done (time: " . sprintf('%.3f', microtime(true) - $time) . "s)\n"; + } + + public function update($collection, $columns, $condition = '', $params = []) + { + echo " > update $collection ..."; + $time = microtime(true); + (new Query())->update($collection, $columns, $condition, $params)->execute(); + echo " done (time: " . sprintf('%.3f', microtime(true) - $time) . "s)\n"; + } + + public function delete($collection, $condition = '', $params = []) + { + echo " > delete from $collection ..."; + $time = microtime(true); + (new Query())->remove($collection, $condition, $params)->execute(); + echo " done (time: " . sprintf('%.3f', microtime(true) - $time) . "s)\n"; + } + + public function createCollection($collection, $options = []) + { + echo " > create collection $collection ..."; + $time = microtime(true); + $this->db->getCollectionHandler()->create($collection, $options); + echo " done (time: " . sprintf('%.3f', microtime(true) - $time) . "s)\n"; + } + + public function dropCollection($collection) + { + echo " > drop collection $collection ..."; + $time = microtime(true); + $this->db->getCollectionHandler()->drop($collection); + echo " done (time: " . sprintf('%.3f', microtime(true) - $time) . "s)\n"; + } + + public function truncateCollection($collection) + { + echo " > truncate collection $collection ..."; + $time = microtime(true); + $this->db->getCollectionHandler()->truncate($collection); + echo " done (time: " . sprintf('%.3f', microtime(true) - $time) . "s)\n"; + } +} diff --git a/Query.php b/Query.php new file mode 100644 index 0000000..1ed575e --- /dev/null +++ b/Query.php @@ -0,0 +1,606 @@ + 'buildNotCondition', + 'AND' => 'buildAndCondition', + 'OR' => 'buildAndCondition', + 'IN' => 'buildInCondition', + 'LIKE' => 'buildLikeCondition', + ]; + + protected $conditionMap = [ + 'NOT' => '!', + 'AND' => '&&', + 'OR' => '||', + 'IN' => 'in', + 'LIKE' => 'LIKE', + ]; + + public $select = []; + + public $from; + + public $where; + + public $limit; + + public $offset; + + public $orderBy; + + public $indexBy; + + public $params = []; + + /** + * @param array $options + * @param null|Connection $db + * @return null|Statement + */ + private function getStatement($options = [], $db = null) + { + if ($db === null) { + $db = Yii::$app->get('arangodb'); + } + + return $db->getStatement($options); + } + + public function createCommand($options = []) + { + list ($aql, $params) = $this->buildQuery($this); + + $options = ArrayHelper::merge( + $options, + [ + 'query' => $aql, + 'bindVars' => $params, + ] + ); + + return $this->getStatement($options); + } + + public function update($collection, $columns, $condition, $params) + { + $clauses = [ + $this->buildFrom($collection), + $this->buildWhere($condition, $params), + $this->buildUpdate($collection, $columns), + ]; + + $aql = implode($this->separator, array_filter($clauses)); + + $options = ArrayHelper::merge( + $params, + [ + 'query' => $aql, + 'bindVars' => $params, + ] + ); + + return $this->getStatement($options); + } + + public function remove($collection, $condition, $params) + { + $clauses = [ + $this->buildFrom($collection), + $this->buildWhere($condition, $params), + $this->buildRemove($collection), + ]; + + $aql = implode($this->separator, array_filter($clauses)); + + $options = ArrayHelper::merge( + $params, + [ + 'query' => $aql, + 'bindVars' => $params, + ] + ); + + return $this->getStatement($options); + } + + protected function buildUpdate($collection, $columns) + { + return 'UPDATE ' . $collection . 'WITH ' . Json::encode($columns) . ' IN ' . $collection; + } + + protected function buildRemove($collection) + { + return 'REMOVE ' . $collection . ' IN ' . $collection; + } + + /** + * @param $fields + * @return $this + */ + public function select($fields) + { + $this->select = $fields; + + return $this; + } + + public function from($collection) + { + $this->from = $collection; + + return $this; + } + + protected function buildFrom($collection) + { + $collection = trim($collection); + return $collection ? "FOR $collection IN $collection" : ''; + } + + public function quoteCollectionName($name) + { + if (strpos($name, '(') !== false || strpos($name, '{{') !== false) { + return $name; + } + if (strpos($name, '.') === false) { + return $name; + } + $parts = explode('.', $name); + foreach ($parts as $i => $part) { + $parts[$i] = $part; + } + + return implode('.', $parts); + + } + + public function quoteColumnName($name) + { + if (strpos($name, '(') !== false || strpos($name, '[[') !== false || strpos($name, '{{') !== false) { + return $name; + } + if (($pos = strrpos($name, '.')) !== false) { + $prefix = substr($name, 0, $pos); + $prefix = $this->quoteCollectionName($prefix) . '.'; + $name = substr($name, $pos + 1); + } else { + $prefix = $this->quoteCollectionName($this->from) . '.'; + } + + return $prefix . $name; + } + + protected function buildWhere($condition, &$params) + { + $where = $this->buildCondition($condition, $params); + + return $where === '' ? '' : 'FILTER ' . $where; + } + + protected function buildCondition($condition, &$params) + { + if (!is_array($condition)) { + return (string) $condition; + } elseif (empty($condition)) { + return ''; + } + + if (isset($condition[0])) { // operator format: operator, operand 1, operand 2, ... + $operator = strtoupper($condition[0]); + if (isset($this->conditionBuilders[$operator])) { + $method = $this->conditionBuilders[$operator]; + array_shift($condition); + return $this->$method($operator, $condition, $params); + } else { + throw new InvalidParamException('Found unknown operator in query: ' . $operator); + } + } else { // hash format: 'column1' => 'value1', 'column2' => 'value2', ... + return $this->buildHashCondition($condition, $params); + } + } + + protected function buildHashCondition($condition, &$params) + { + $parts = []; + foreach ($condition as $column => $value) { + if (is_array($value) || $value instanceof Query) { + // IN condition + $parts[] = $this->buildInCondition('IN', [$column, $value], $params); + } else { + if (strpos($column, '(') === false) { + $column = $this->quoteColumnName($column); + } + if ($value === null) { + $parts[] = "$column == null"; + } else { + $phName = self::PARAM_PREFIX . count($params); + $parts[] = "$column==@$phName"; + $params[$phName] = $value; + } + } + } + return count($parts) === 1 ? $parts[0] : '(' . implode(') && (', $parts) . ')'; + } + + protected function buildAndCondition($operator, $operands, &$params) + { + $parts = []; + foreach ($operands as $operand) { + if (is_array($operand)) { + $operand = $this->buildCondition($operand, $params); + } + if ($operand !== '') { + $parts[] = $operand; + } + } + if (!empty($parts)) { + return '(' . implode(") {$this->conditionMap[$operator]} (", $parts) . ')'; + } else { + return ''; + } + } + + protected function buildNotCondition($operator, $operands, &$params) + { + if (count($operands) != 1) { + throw new InvalidParamException("Operator '$operator' requires exactly one operand."); + } + + $operand = reset($operands); + if (is_array($operand)) { + $operand = $this->buildCondition($operand, $params); + } + if ($operand === '') { + return ''; + } + + return "{$this->conditionMap[$operator]} ($operand)"; + } + + protected function buildInCondition($operator, $operands, &$params) + { + if (!isset($operands[0], $operands[1])) { + throw new Exception("Operator '$operator' requires two operands."); + } + + list($column, $values) = $operands; + + if ($values === [] || $column === []) { + return $operator === 'IN' ? '0=1' : ''; + } + + if ($values instanceof Query) { + // sub-query + list($sql, $params) = $this->buildQuery($values, $params); + $column = (array)$column; + if (is_array($column)) { + foreach ($column as $i => $col) { + if (strpos($col, '(') === false) { + $column[$i] = $this->quoteColumnName($col); + } + } + return '(' . implode(', ', $column) . ") {$this->conditionMap[$operator]} ($sql)"; + } else { + if (strpos($column, '(') === false) { + $column = $this->quoteColumnName($column); + } + return "$column {$this->conditionMap[$operator]} ($sql)"; + } + } + + $values = (array) $values; + + if (count($column) > 1) { + return $this->buildCompositeInCondition($operator, $column, $values, $params); + } + + if (is_array($column)) { + $column = reset($column); + } + foreach ($values as $i => $value) { + if (is_array($value)) { + $value = isset($value[$column]) ? $value[$column] : null; + } + if ($value === null) { + $values[$i] = 'null'; + } else { + $phName = self::PARAM_PREFIX . count($params); + $params[$phName] = $value; + $values[$i] = "@$phName"; + } + } + if (strpos($column, '(') === false) { + $column = $this->quoteColumnName($column); + } + + if (count($values) > 1) { + return "$column {$this->conditionMap[$operator]} [" . implode(', ', $values) . ']'; + } else { + $operator = $operator === 'IN' ? '==' : '!='; + return $column . $operator . reset($values); + } + } + + protected function buildCompositeInCondition($operator, $columns, $values, &$params) + { + $vss = []; + foreach ($values as $value) { + $vs = []; + foreach ($columns as $column) { + if (isset($value[$column])) { + $phName = self::PARAM_PREFIX . count($params); + $params[$phName] = $value[$column]; + $vs[] = "@$phName"; + } else { + $vs[] = 'null'; + } + } + $vss[] = '(' . implode(', ', $vs) . ')'; + } + foreach ($columns as $i => $column) { + if (strpos($column, '(') === false) { + $columns[$i] = $this->quoteColumnName($column); + } + } + + return '(' . implode(', ', $columns) . ") {$this->conditionMap[$operator]} [" . implode(', ', $vss) . ']'; + } + + protected function buildLikeCondition($operator, $condition, &$params) + { + if (!(isset($condition[0]) && isset($condition[1]))) { + throw new InvalidParamException("You must set 'column' and 'pattern' params"); + } + $caseInsensitive = isset($condition[2]) ? (bool)$condition[2] : false; + return $this->conditionMap[$operator] + . '(' + . $this->quoteColumnName($condition[0]) + . ', "' + . $condition[1] + . '", ' + . ($caseInsensitive ? 'TRUE' : 'FALSE') + . ')'; + } + + protected function buildOrderBy($columns) + { + if (empty($columns)) { + return ''; + } + $orders = []; + foreach ($columns as $name => $direction) { + $orders[] = $this->quoteColumnName($name) . ($direction === SORT_DESC ? ' DESC' : ''); + } + + return 'SORT ' . implode(', ', $orders); + } + + protected function hasLimit($limit) + { + return is_string($limit) && ctype_digit($limit) || is_integer($limit) && $limit >= 0; + } + + protected function hasOffset($offset) + { + return is_integer($offset) && $offset > 0 || is_string($offset) && ctype_digit($offset) && $offset !== '0'; + } + + protected function buildLimit($limit, $offset) + { + $aql = ''; + if ($this->hasLimit($limit)) { + $aql = 'LIMIT ' . $limit; + if ($this->hasOffset($offset)) { + $aql .= ', ' . $offset; + } + } + + return $aql; + } + + protected function buildSelect($columns, &$params) + { + if ($columns == null || empty($columns)) { + return 'RETURN ' . $this->from; + } + + if (!is_array($columns)) { + return 'RETURN ' . $columns; + } + + $names = ''; + foreach ($columns as $name => $column) { + if (is_int($name)) { + $names .= $column . ', '; + } else { + $names .= "\"$name\": $this->from.$column, "; + } + } + + return 'RETURN {' . trim($names, ', ') . '}'; + } + + protected function buildQuery($query = null, $params = []) + { + $query = isset($query) ? $query : $this; + + if ($query->where === null) { + $where = []; + } else { + $where = $query->where; + } + + $params = empty($params) ? $query->params : array_merge($params, $query->params); + + $clauses = [ + $this->buildFrom($query->from), + $this->buildWhere($where, $params), + $this->buildOrderBy($query->orderBy, $params), + $this->buildLimit($query->limit, $query->offset, $params), + $this->buildSelect($query->select, $params), + ]; + + $aql = implode($query->separator, array_filter($clauses)); + + return [$aql, $params]; + } + + public function all($db = null) + { + $statement = $this->createCommand(); + $cursor = $statement->execute(); + return $cursor->getAll(); + } + + public function one($db = null) + { + $this->limit(1); + $statement = $this->createCommand(); + $cursor = $statement->execute(); + $result = $cursor->getAll(); + return empty($result) ? false : $result[0]; + } + + public function prepareResult($rows) + { + if ($this->indexBy === null) { + return $rows; + } + $result = []; + foreach ($rows as $row) { + if (is_string($this->indexBy)) { + $key = $row[$this->indexBy]; + } else { + $key = call_user_func($this->indexBy, $row); + } + $result[$key] = $row; + } + return $result; + } + + public function count($q = '*', $db = null) + { + $statement = $this->createCommand(); + $statement->setCount(true); + $cursor = $statement->execute(); + return $cursor->getCount(); + } + + public function exists($db = null) + { + $record = $this->one($db); + return !empty($record); + } + + public function indexBy($column) + { + $this->indexBy = $column; + return $this; + } + + public function where($condition) + { + $this->where = $condition; + return $this; + } + + public function andWhere($condition) + { + if ($this->where === null) { + $this->where = $condition; + } else { + $this->where = ['AND', $this->where, $condition]; + } + return $this; + } + + public function orWhere($condition) + { + if ($this->where === null) { + $this->where = $condition; + } else { + $this->where = ['OR', $this->where, $condition]; + } + return $this; + } + + public function filterWhere(array $condition) + { + // TODO: Implement filterWhere() method. + } + + public function andFilterWhere(array $condition) + { + // TODO: Implement andFilterWhere() method. + } + + public function orFilterWhere(array $condition) + { + // TODO: Implement orFilterWhere() method. + } + + public function orderBy($columns) + { + $this->orderBy = $this->normalizeOrderBy($columns); + return $this; + } + + public function addOrderBy($columns) + { + $columns = $this->normalizeOrderBy($columns); + if ($this->orderBy === null) { + $this->orderBy = $columns; + } else { + $this->orderBy = array_merge($this->orderBy, $columns); + } + return $this; + } + + public function limit($limit) + { + $this->limit = $limit; + return $this; + } + + public function offset($offset) + { + $this->offset = $offset; + return $this; + } + + protected function normalizeOrderBy($columns) + { + if (is_array($columns)) { + return $columns; + } else { + $columns = preg_split('/\s*,\s*/', trim($columns), -1, PREG_SPLIT_NO_EMPTY); + $result = []; + foreach ($columns as $column) { + if (preg_match('/^(.*?)\s+(asc|desc)$/i', $column, $matches)) { + $result[$matches[1]] = strcasecmp($matches[2], 'desc') ? SORT_ASC : SORT_DESC; + } else { + $result[$column] = SORT_ASC; + } + } + return $result; + } + } +} diff --git a/composer.json b/composer.json index 3d913d1..ce3d828 100644 --- a/composer.json +++ b/composer.json @@ -8,6 +8,14 @@ { "name": "Alexander Kozhevnikov", "email": "b37hr3z3n@gmail.com" + }, + { + "name": "Evgeny Dubovitsky", + "email": "flynn068@gmail.com" + }, + { + "name": "Pavel Fedotov", + "email": "fps.06@mail.ru" } ], "require": { diff --git a/console/controllers/MigrateController.php b/console/controllers/MigrateController.php new file mode 100644 index 0000000..abd886c --- /dev/null +++ b/console/controllers/MigrateController.php @@ -0,0 +1,159 @@ +id !== 'create') { + if (is_string($this->db)) { + $this->db = \Yii::$app->get($this->db); + } + if (!$this->db instanceof Connection) { + throw new Exception("The 'db' option must refer to the application component ID of a ArangoDB connection."); + } + } + return true; + } else { + return false; + } + } + + /** + * Creates a new migration instance. + * @param string $class the migration class name + * @return Migration the migration instance + */ + protected function createMigration($class) + { + $file = $this->migrationPath . DIRECTORY_SEPARATOR . $class . '.php'; + require_once($file); + + return new $class(['db' => $this->db]); + } + + /** + * @inheritdoc + */ + protected function getMigrationHistory($limit) + { + try { + $history = $this->getHistory($limit); + } catch (ServerException $ex) { + if ($ex->getServerCode() == 1203) { + $this->createMigrationHistoryCollection(); + $history = $this->getHistory($limit); + } else { + throw $ex; + } + } + unset($history[self::BASE_MIGRATION]); + + return $history; + } + + private function getHistory($limit) + { + $query = new Query; + $rows = $query->select(['version' => 'version', 'apply_time' => 'apply_time']) + ->from($this->migrationCollection) + ->orderBy('version DESC') + ->limit($limit) + ->all($this->db); + $history = ArrayHelper::map($rows, 'version', 'apply_time'); + unset($history[self::BASE_MIGRATION]); + + return $history; + } + + protected function createMigrationHistoryCollection() + { + echo "Creating migration history collection \"$this->migrationCollection\"..."; + $this->db->getCollectionHandler()->create($this->migrationCollection); + $this->db->getDocumentHandler()->save( + $this->migrationCollection, + [ + 'version' => self::BASE_MIGRATION, + 'apply_time' => time(), + ] + ); + echo "done.\n"; + } + + /** + * @inheritdoc + */ + protected function addMigrationHistory($version) + { + $this->db->getDocumentHandler()->save( + $this->migrationCollection, + [ + 'version' => $version, + 'apply_time' => time(), + ] + ); + } + + /** + * @inheritdoc + */ + protected function removeMigrationHistory($version) + { + $this->db->getCollectionHandler()->removeByExample( + $this->migrationCollection, + [ + 'version' => $version, + ] + ); + } +} diff --git a/views/migration.php b/views/migration.php new file mode 100644 index 0000000..1c0ca21 --- /dev/null +++ b/views/migration.php @@ -0,0 +1,24 @@ + + +class extends \app\components\arangodb\Migration +{ + public function up() + { + + } + + public function down() + { + echo " cannot be reverted.\n"; + + return false; + } +}