From e0429ca44c285b92b17246dcdd29174429a8c158 Mon Sep 17 00:00:00 2001 From: Paul Klimov Date: Fri, 1 Nov 2013 14:51:36 +0200 Subject: [PATCH 01/59] Sphinx extensions skeleton created. --- extensions/sphinx/LICENSE.md | 32 ++++++++ extensions/sphinx/README.md | 42 ++++++++++ extensions/sphinx/composer.json | 27 +++++++ extensions/sphinx/yii/sphinx/Connection.php | 19 +++++ tests/unit/extensions/sphinx/ConnectionTest.php | 42 ++++++++++ tests/unit/extensions/sphinx/SphinxTestCase.php | 101 ++++++++++++++++++++++++ 6 files changed, 263 insertions(+) create mode 100644 extensions/sphinx/LICENSE.md create mode 100644 extensions/sphinx/README.md create mode 100644 extensions/sphinx/composer.json create mode 100644 extensions/sphinx/yii/sphinx/Connection.php create mode 100644 tests/unit/extensions/sphinx/ConnectionTest.php create mode 100644 tests/unit/extensions/sphinx/SphinxTestCase.php diff --git a/extensions/sphinx/LICENSE.md b/extensions/sphinx/LICENSE.md new file mode 100644 index 0000000..0bb1a8d --- /dev/null +++ b/extensions/sphinx/LICENSE.md @@ -0,0 +1,32 @@ +The Yii framework is free software. It is released under the terms of +the following BSD License. + +Copyright © 2008-2013 by Yii Software LLC (http://www.yiisoft.com) +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + * Neither the name of Yii Software LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff --git a/extensions/sphinx/README.md b/extensions/sphinx/README.md new file mode 100644 index 0000000..fad7613 --- /dev/null +++ b/extensions/sphinx/README.md @@ -0,0 +1,42 @@ +Yii 2.0 Public Preview - Sphinx Extension +========================================= + +Thank you for choosing Yii - a high-performance component-based PHP framework. + +If you are looking for a production-ready PHP framework, please use +[Yii v1.1](https://github.com/yiisoft/yii). + +Yii 2.0 is still under heavy development. We may make significant changes +without prior notices. **Yii 2.0 is not ready for production use yet.** + +[![Build Status](https://secure.travis-ci.org/yiisoft/yii2.png)](http://travis-ci.org/yiisoft/yii2) + +This is the yii2-sphinx extension. + + +Installation +------------ + +The preferred way to install this extension is through [composer](http://getcomposer.org/download/). + +Either run +``` +php composer.phar require yiisoft/yii2-sphinx "*" +``` + +or add +``` +"yiisoft/yii2-sphinx": "*" +``` +to the require section of your composer.json. + + +*Note: You might have to run `php composer.phar selfupdate`* + + +Usage & Documentation +--------------------- + +This extension adds [Sphinx](http://sphinxsearch.com/docs) full text search engine extension for the Yii framework. +This extension interact with Sphinx search daemon using MySQL protocol and [SphinxQL](http://sphinxsearch.com/docs/current.html#sphinxql) query language. + diff --git a/extensions/sphinx/composer.json b/extensions/sphinx/composer.json new file mode 100644 index 0000000..a42358e --- /dev/null +++ b/extensions/sphinx/composer.json @@ -0,0 +1,27 @@ +{ + "name": "yiisoft/yii2-sphinx", + "description": "Sphinx full text search engine extension for the Yii framework", + "keywords": ["yii", "sphinx", "search", "fulltext"], + "type": "yii2-extension", + "license": "BSD-3-Clause", + "support": { + "issues": "https://github.com/yiisoft/yii2/issues?state=open", + "forum": "http://www.yiiframework.com/forum/", + "wiki": "http://www.yiiframework.com/wiki/", + "irc": "irc://irc.freenode.net/yii", + "source": "https://github.com/yiisoft/yii2" + }, + "authors": [ + { + "name": "Paul Klimov", + "email": "klimov.paul@gmail.com" + } + ], + "minimum-stability": "dev", + "require": { + "yiisoft/yii2": "*" + }, + "autoload": { + "psr-0": { "yii\\sphinx\\": "" } + } +} diff --git a/extensions/sphinx/yii/sphinx/Connection.php b/extensions/sphinx/yii/sphinx/Connection.php new file mode 100644 index 0000000..c63ec7d --- /dev/null +++ b/extensions/sphinx/yii/sphinx/Connection.php @@ -0,0 +1,19 @@ + + * @since 2.0 + */ +class Connection extends \yii\db\Connection +{ + // +} \ No newline at end of file diff --git a/tests/unit/extensions/sphinx/ConnectionTest.php b/tests/unit/extensions/sphinx/ConnectionTest.php new file mode 100644 index 0000000..803f627 --- /dev/null +++ b/tests/unit/extensions/sphinx/ConnectionTest.php @@ -0,0 +1,42 @@ +getConnection(false); + $params = $this->sphinxConfig; + + $this->assertEquals($params['dsn'], $connection->dsn); + $this->assertEquals($params['username'], $connection->username); + $this->assertEquals($params['password'], $connection->password); + } + + public function testOpenClose() + { + $connection = $this->getConnection(false, false); + + $this->assertFalse($connection->isActive); + $this->assertEquals(null, $connection->pdo); + + $connection->open(); + $this->assertTrue($connection->isActive); + $this->assertTrue($connection->pdo instanceof \PDO); + + $connection->close(); + $this->assertFalse($connection->isActive); + $this->assertEquals(null, $connection->pdo); + + $connection = new Connection; + $connection->dsn = 'unknown::memory:'; + $this->setExpectedException('yii\db\Exception'); + $connection->open(); + } +} \ No newline at end of file diff --git a/tests/unit/extensions/sphinx/SphinxTestCase.php b/tests/unit/extensions/sphinx/SphinxTestCase.php new file mode 100644 index 0000000..c643c65 --- /dev/null +++ b/tests/unit/extensions/sphinx/SphinxTestCase.php @@ -0,0 +1,101 @@ + 'mysql:host=127.0.0.1;port=9306;', + 'username' => '', + 'password' => '', + ]; + /** + * @var Connection + */ + protected $sphinx; + + public static function setUpBeforeClass() + { + static::loadClassMap(); + } + + protected function setUp() + { + parent::setUp(); + //$this->sphinxConfig = $this->getParam('sphinx'); + if (!extension_loaded('pdo') || !extension_loaded('pdo_mysql')) { + $this->markTestSkipped('pdo and pdo_mysql extension are required.'); + } + $this->mockApplication(); + static::loadClassMap(); + } + + protected function tearDown() + { + if ($this->sphinx) { + $this->sphinx->close(); + } + $this->destroyApplication(); + } + + /** + * Adds sphinx extension files to [[Yii::$classPath]], + * avoiding the necessity of usage Composer autoloader. + */ + protected static function loadClassMap() + { + $baseNameSpace = 'yii/sphinx'; + $basePath = realpath(__DIR__. '/../../../../extensions/sphinx/yii/sphinx'); + $files = FileHelper::findFiles($basePath); + foreach ($files as $file) { + $classRelativePath = str_replace($basePath, '', $file); + $classFullName = str_replace(['/', '.php'], ['\\', ''], $baseNameSpace . $classRelativePath); + Yii::$classMap[$classFullName] = $file; + } + } + + /** + * @param bool $reset whether to clean up the test database + * @param bool $open whether to open and populate test database + * @return \yii\db\Connection + */ + public function getConnection($reset = true, $open = true) + { + if (!$reset && $this->sphinx) { + return $this->sphinx; + } + $db = new \yii\db\Connection; + $db->dsn = $this->sphinxConfig['dsn']; + if (isset($this->sphinxConfig['username'])) { + $db->username = $this->sphinxConfig['username']; + $db->password = $this->sphinxConfig['password']; + } + if (isset($this->sphinxConfig['attributes'])) { + $db->attributes = $this->sphinxConfig['attributes']; + } + if ($open) { + $db->open(); + if (!empty($this->sphinxConfig['fixture'])) { + $lines = explode(';', file_get_contents($this->sphinxConfig['fixture'])); + foreach ($lines as $line) { + if (trim($line) !== '') { + $db->pdo->exec($line); + } + } + } + } + $this->sphinx = $db; + return $db; + } +} \ No newline at end of file From f7b7c758b68a6b73f6e0aa5e579ea2448d6c62bd Mon Sep 17 00:00:00 2001 From: Paul Klimov Date: Fri, 1 Nov 2013 16:52:31 +0200 Subject: [PATCH 02/59] Sphinx test environment setup files added. --- extensions/sphinx/yii/sphinx/Connection.php | 8 ++- extensions/sphinx/yii/sphinx/Schema.php | 19 +++++++ tests/unit/data/sphinx/sphinx.conf | 86 +++++++++++++++++++++++++++++ tests/unit/data/sphinx/sphinx.sql | 71 ++++++++++++++++++++++++ 4 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 extensions/sphinx/yii/sphinx/Schema.php create mode 100644 tests/unit/data/sphinx/sphinx.conf create mode 100644 tests/unit/data/sphinx/sphinx.sql diff --git a/extensions/sphinx/yii/sphinx/Connection.php b/extensions/sphinx/yii/sphinx/Connection.php index c63ec7d..a36afb9 100644 --- a/extensions/sphinx/yii/sphinx/Connection.php +++ b/extensions/sphinx/yii/sphinx/Connection.php @@ -15,5 +15,11 @@ namespace yii\sphinx; */ class Connection extends \yii\db\Connection { - // + /** + * @inheritdoc + */ + public $schemaMap = [ + 'mysqli' => 'yii\sphinx\Schema', // MySQL + 'mysql' => 'yii\sphinx\Schema', // MySQL + ]; } \ No newline at end of file diff --git a/extensions/sphinx/yii/sphinx/Schema.php b/extensions/sphinx/yii/sphinx/Schema.php new file mode 100644 index 0000000..14d0772 --- /dev/null +++ b/extensions/sphinx/yii/sphinx/Schema.php @@ -0,0 +1,19 @@ + + * @since 2.0 + */ +class Schema extends \yii\db\mysql\Schema +{ + // +} \ No newline at end of file diff --git a/tests/unit/data/sphinx/sphinx.conf b/tests/unit/data/sphinx/sphinx.conf new file mode 100644 index 0000000..6f61583 --- /dev/null +++ b/tests/unit/data/sphinx/sphinx.conf @@ -0,0 +1,86 @@ +# +# Minimal Sphinx configuration sample (clean, simple, functional) +# + +source yii2_test_article_src +{ + type = mysql + + sql_host = localhost + sql_user = + sql_pass = + sql_db = yii2test + sql_port = 3306 # optional, default is 3306 + + sql_query = \ + SELECT *, UNIX_TIMESTAMP(create_date) AS add_date \ + FROM yii2_test_article + + sql_attr_uint = id + sql_attr_timestamp = add_date + + sql_query_info = SELECT * FROM yii2_test_article WHERE id=$id +} + + +source yii2_test_item_src +{ + type = mysql + + sql_host = localhost + sql_user = + sql_pass = + sql_db = yii2test + sql_port = 3306 # optional, default is 3306 + + sql_query = \ + SELECT *, CURRENT_TIMESTAMP() AS add_date \ + FROM yii2_test_item + + sql_attr_uint = id + sql_attr_timestamp = add_date + + sql_query_info = SELECT * FROM yii2_test_item WHERE id=$id +} + + +index yii2_test_article_index +{ + source = yii2_test_article_src + path = /var/lib/sphinx/yii2_test_article_src + docinfo = extern + charset_type = sbcs +} + + +index yii2_test_item_index +{ + source = yii2_test_item_src + path = /var/lib/sphinx/yii2_test_item_src + docinfo = extern + charset_type = sbcs +} + + +indexer +{ + mem_limit = 32M +} + + +searchd +{ + listen = 127.0.0.1:9312 + listen = 9306:mysql41 + log = /var/log/sphinx/searchd.log + query_log = /var/log/sphinx/query.log + read_timeout = 5 + max_children = 30 + pid_file = /var/run/sphinx/searchd.pid + max_matches = 1000 + seamless_rotate = 1 + preopen_indexes = 1 + unlink_old = 1 + workers = threads # for RT to work + binlog_path = /var/lib/sphinx +} diff --git a/tests/unit/data/sphinx/sphinx.sql b/tests/unit/data/sphinx/sphinx.sql new file mode 100644 index 0000000..a960e50 --- /dev/null +++ b/tests/unit/data/sphinx/sphinx.sql @@ -0,0 +1,71 @@ +-- phpMyAdmin SQL Dump +-- version 3.5.0 +-- http://www.phpmyadmin.net +-- +-- Host: 10.10.50.201 +-- Generation Time: Nov 01, 2013 at 04:38 PM +-- Server version: 5.1.69-log +-- PHP Version: 5.3.3 + +SET SQL_MODE="NO_AUTO_VALUE_ON_ZERO"; +SET time_zone = "+00:00"; + + +/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; +/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */; +/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */; +/*!40101 SET NAMES utf8 */; + +-- +-- Database: `yiiexample` +-- + +-- -------------------------------------------------------- + +-- +-- Table structure for table `yii2_test_article` +-- + +CREATE TABLE IF NOT EXISTS `yii2_test_article` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `title` varchar(255) NOT NULL, + `content` text NOT NULL, + `author_id` int(11) NOT NULL, + `create_date` datetime NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=MyISAM DEFAULT CHARSET=utf8 AUTO_INCREMENT=3 ; + +-- +-- Dumping data for table `yii2_test_article` +-- + +INSERT INTO `yii2_test_article` (`id`, `title`, `content`, `author_id`, `create_date`) VALUES +(1, 'About cats', 'This article is about cats', 1, '2013-10-23 00:00:00'), +(2, 'About dogs', 'This article is about dogs', 2, '2013-11-15 00:00:00'); + +-- -------------------------------------------------------- + +-- +-- Table structure for table `yii2_test_item` +-- + +CREATE TABLE IF NOT EXISTS `yii2_test_item` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `name` varchar(255) NOT NULL, + `description` text NOT NULL, + `category_id` int(11) NOT NULL, + `price` float NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=MyISAM DEFAULT CHARSET=utf8 AUTO_INCREMENT=3 ; + +-- +-- Dumping data for table `yii2_test_item` +-- + +INSERT INTO `yii2_test_item` (`id`, `name`, `description`, `category_id`, `price`) VALUES +(1, 'pencil', 'Simple pencil', 1, 2.5), +(2, 'table', 'Wooden table', 2, 100); + +/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */; +/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */; +/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; From b9137dfef6b9b47b148dfed6aa584261e5962cb5 Mon Sep 17 00:00:00 2001 From: Paul Klimov Date: Thu, 7 Nov 2013 16:58:39 +0200 Subject: [PATCH 03/59] Sphinx command and query added. --- .travis.yml | 2 +- extensions/sphinx/yii/sphinx/Command.php | 19 + extensions/sphinx/yii/sphinx/Query.php | 537 ++++++++++++++++++++++++++ extensions/sphinx/yii/sphinx/QueryBuilder.php | 75 ++++ extensions/sphinx/yii/sphinx/Schema.php | 10 +- tests/unit/data/sphinx/sphinx.conf | 3 + tests/unit/data/sphinx/sphinx.sql | 43 --- tests/unit/extensions/sphinx/CommandTest.php | 85 ++++ 8 files changed, 729 insertions(+), 45 deletions(-) create mode 100644 extensions/sphinx/yii/sphinx/Command.php create mode 100644 extensions/sphinx/yii/sphinx/Query.php create mode 100644 extensions/sphinx/yii/sphinx/QueryBuilder.php create mode 100644 tests/unit/extensions/sphinx/CommandTest.php diff --git a/.travis.yml b/.travis.yml index a905b36..306f53c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -18,7 +18,7 @@ before_script: - tests/unit/data/travis/cubrid-setup.sh script: - - phpunit --coverage-clover tests/unit/runtime/coveralls/clover.xml --verbose --exclude-group mssql,oci,wincache,xcache,zenddata + - phpunit --coverage-clover tests/unit/runtime/coveralls/clover.xml --verbose --exclude-group mssql,oci,wincache,xcache,zenddata,sphinx after_script: - php vendor/bin/coveralls diff --git a/extensions/sphinx/yii/sphinx/Command.php b/extensions/sphinx/yii/sphinx/Command.php new file mode 100644 index 0000000..b457b9a --- /dev/null +++ b/extensions/sphinx/yii/sphinx/Command.php @@ -0,0 +1,19 @@ + + * @since 2.0 + */ +class Command extends \yii\db\Command +{ + // +} \ No newline at end of file diff --git a/extensions/sphinx/yii/sphinx/Query.php b/extensions/sphinx/yii/sphinx/Query.php new file mode 100644 index 0000000..0d76355 --- /dev/null +++ b/extensions/sphinx/yii/sphinx/Query.php @@ -0,0 +1,537 @@ + + * @since 2.0 + */ +class Query extends Component +{ + /** + * Sort ascending + * @see orderBy + */ + const SORT_ASC = false; + /** + * Sort descending + * @see orderBy + */ + const SORT_DESC = true; + + public $select; + /** + * @var string additional option that should be appended to the 'SELECT' keyword. For example, + * in MySQL, the option 'SQL_CALC_FOUND_ROWS' can be used. + */ + public $selectOption; + /** + * @var boolean whether to select distinct rows of data only. If this is set true, + * the SELECT clause would be changed to SELECT DISTINCT. + */ + public $distinct; + public $from; + public $where; + public $limit; + public $offset; + public $orderBy; + public $groupBy; + /** + * @var string WITHIN GROUP ORDER BY clause. This is a Sphinx specific extension + * that lets you control how the best row within a group will to be selected. + */ + public $within; + /** + * @var array per-query options in format: optionName => optionValue + * They will compose OPTION clause. This is a Sphinx specific extension + * that lets you control a number of per-query options. + */ + public $options; + /** + * @var array list of query parameter values indexed by parameter placeholders. + * For example, `[':name' => 'Dan', ':age' => 31]`. + */ + public $params; + /** + * @var string|callable $column the name of the column by which the query results should be indexed by. + * This can also be a callable (e.g. anonymous function) that returns the index value based on the given + * row data. For more details, see [[indexBy()]]. This property is only used by [[all()]]. + */ + public $indexBy; + + /** + * Creates a Sphinx command that can be used to execute this query. + * @param Connection $sphinxConnection the Sphinx connection used to generate the SQL statement. + * If this parameter is not given, the `sphinx` application component will be used. + * @return Command the created Sphinx command instance. + */ + public function createCommand($sphinxConnection = null) + { + if ($sphinxConnection === null) { + $sphinxConnection = Yii::$app->getComponent('sphinx'); + } + list ($sql, $params) = $sphinxConnection->getQueryBuilder()->build($this); + return $sphinxConnection->createCommand($sql, $params); + } + + /** + * Sets the [[indexBy]] property. + * @param string|callable $column the name of the column by which the query results should be indexed by. + * This can also be a callable (e.g. anonymous function) that returns the index value based on the given + * row data. The signature of the callable should be: + * + * ~~~ + * function ($row) + * { + * // return the index value corresponding to $row + * } + * ~~~ + * + * @return static the query object itself + */ + public function indexBy($column) + { + $this->indexBy = $column; + return $this; + } + + /** + * Executes the query and returns all results as an array. + * @param Connection $db the database connection used to generate the SQL statement. + * If this parameter is not given, the `db` application component will be used. + * @return array the query results. If the query results in nothing, an empty array will be returned. + */ + public function all($db = null) + { + $rows = $this->createCommand($db)->queryAll(); + 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; + } + + /** + * Executes the query and returns a single row of result. + * @param Connection $db the database connection used to generate the SQL statement. + * If this parameter is not given, the `db` application component will be used. + * @return array|boolean the first row (in terms of an array) of the query result. False is returned if the query + * results in nothing. + */ + public function one($db = null) + { + return $this->createCommand($db)->queryOne(); + } + + /** + * Returns the query result as a scalar value. + * The value returned will be the first column in the first row of the query results. + * @param Connection $db the database connection used to generate the SQL statement. + * If this parameter is not given, the `db` application component will be used. + * @return string|boolean the value of the first column in the first row of the query result. + * False is returned if the query result is empty. + */ + public function scalar($db = null) + { + return $this->createCommand($db)->queryScalar(); + } + + /** + * Executes the query and returns the first column of the result. + * @param Connection $db the database connection used to generate the SQL statement. + * If this parameter is not given, the `db` application component will be used. + * @return array the first column of the query result. An empty array is returned if the query results in nothing. + */ + public function column($db = null) + { + return $this->createCommand($db)->queryColumn(); + } + + /** + * Returns the number of records. + * @param string $q the COUNT expression. Defaults to '*'. + * Make sure you properly quote column names in the expression. + * @param Connection $db the database connection used to generate the SQL statement. + * If this parameter is not given, the `db` application component will be used. + * @return integer number of records + */ + public function count($q = '*', $db = null) + { + $this->select = ["COUNT($q)"]; + return $this->createCommand($db)->queryScalar(); + } + + /** + * Returns the sum of the specified column values. + * @param string $q the column name or expression. + * Make sure you properly quote column names in the expression. + * @param Connection $db the database connection used to generate the SQL statement. + * If this parameter is not given, the `db` application component will be used. + * @return integer the sum of the specified column values + */ + public function sum($q, $db = null) + { + $this->select = ["SUM($q)"]; + return $this->createCommand($db)->queryScalar(); + } + + /** + * Returns the average of the specified column values. + * @param string $q the column name or expression. + * Make sure you properly quote column names in the expression. + * @param Connection $db the database connection used to generate the SQL statement. + * If this parameter is not given, the `db` application component will be used. + * @return integer the average of the specified column values. + */ + public function average($q, $db = null) + { + $this->select = ["AVG($q)"]; + return $this->createCommand($db)->queryScalar(); + } + + /** + * Returns the minimum of the specified column values. + * @param string $q the column name or expression. + * Make sure you properly quote column names in the expression. + * @param Connection $db the database connection used to generate the SQL statement. + * If this parameter is not given, the `db` application component will be used. + * @return integer the minimum of the specified column values. + */ + public function min($q, $db = null) + { + $this->select = ["MIN($q)"]; + return $this->createCommand($db)->queryScalar(); + } + + /** + * Returns the maximum of the specified column values. + * @param string $q the column name or expression. + * Make sure you properly quote column names in the expression. + * @param Connection $db the database connection used to generate the SQL statement. + * If this parameter is not given, the `db` application component will be used. + * @return integer the maximum of the specified column values. + */ + public function max($q, $db = null) + { + $this->select = ["MAX($q)"]; + return $this->createCommand($db)->queryScalar(); + } + + /** + * Returns a value indicating whether the query result contains any row of data. + * @param Connection $db the database connection used to generate the SQL statement. + * If this parameter is not given, the `db` application component will be used. + * @return boolean whether the query result contains any row of data. + */ + public function exists($db = null) + { + $this->select = [new Expression('1')]; + return $this->scalar($db) !== false; + } + + /** + * Sets the SELECT part of the query. + * @param string|array $columns the columns to be selected. + * Columns can be specified in either a string (e.g. "id, name") or an array (e.g. ['id', 'name']). + * Columns can contain table prefixes (e.g. "tbl_user.id") and/or column aliases (e.g. "tbl_user.id AS user_id"). + * The method will automatically quote the column names unless a column contains some parenthesis + * (which means the column contains a DB expression). + * @param string $option additional option that should be appended to the 'SELECT' keyword. For example, + * in MySQL, the option 'SQL_CALC_FOUND_ROWS' can be used. + * @return static the query object itself + */ + public function select($columns, $option = null) + { + if (!is_array($columns)) { + $columns = preg_split('/\s*,\s*/', trim($columns), -1, PREG_SPLIT_NO_EMPTY); + } + $this->select = $columns; + $this->selectOption = $option; + return $this; + } + + /** + * Sets the value indicating whether to SELECT DISTINCT or not. + * @param bool $value whether to SELECT DISTINCT or not. + * @return static the query object itself + */ + public function distinct($value = true) + { + $this->distinct = $value; + return $this; + } + + /** + * Sets the FROM part of the query. + * @param string|array $tables the table(s) to be selected from. This can be either a string (e.g. `'tbl_user'`) + * or an array (e.g. `['tbl_user', 'tbl_profile']`) specifying one or several table names. + * Table names can contain schema prefixes (e.g. `'public.tbl_user'`) and/or table aliases (e.g. `'tbl_user u'`). + * The method will automatically quote the table names unless it contains some parenthesis + * (which means the table is given as a sub-query or DB expression). + * @return static the query object itself + */ + public function from($tables) + { + if (!is_array($tables)) { + $tables = preg_split('/\s*,\s*/', trim($tables), -1, PREG_SPLIT_NO_EMPTY); + } + $this->from = $tables; + return $this; + } + + public function where($condition, $params = []) + { + $this->where = $condition; + $this->addParams($params); + return $this; + } + + /** + * Adds an additional WHERE condition to the existing one. + * The new condition and the existing one will be joined using the 'AND' operator. + * @param string|array $condition the new WHERE condition. Please refer to [[where()]] + * on how to specify this parameter. + * @param array $params the parameters (name => value) to be bound to the query. + * @return static the query object itself + * @see where() + * @see orWhere() + */ + public function andWhere($condition, $params = []) + { + if ($this->where === null) { + $this->where = $condition; + } else { + $this->where = ['and', $this->where, $condition]; + } + $this->addParams($params); + return $this; + } + + /** + * Adds an additional WHERE condition to the existing one. + * The new condition and the existing one will be joined using the 'OR' operator. + * @param string|array $condition the new WHERE condition. Please refer to [[where()]] + * on how to specify this parameter. + * @param array $params the parameters (name => value) to be bound to the query. + * @return static the query object itself + * @see where() + * @see andWhere() + */ + public function orWhere($condition, $params = []) + { + if ($this->where === null) { + $this->where = $condition; + } else { + $this->where = ['or', $this->where, $condition]; + } + $this->addParams($params); + return $this; + } + + /** + * Sets the GROUP BY part of the query. + * @param string|array $columns the columns to be grouped by. + * Columns can be specified in either a string (e.g. "id, name") or an array (e.g. ['id', 'name']). + * The method will automatically quote the column names unless a column contains some parenthesis + * (which means the column contains a DB expression). + * @return static the query object itself + * @see addGroupBy() + */ + public function groupBy($columns) + { + if (!is_array($columns)) { + $columns = preg_split('/\s*,\s*/', trim($columns), -1, PREG_SPLIT_NO_EMPTY); + } + $this->groupBy = $columns; + return $this; + } + + /** + * Adds additional group-by columns to the existing ones. + * @param string|array $columns additional columns to be grouped by. + * Columns can be specified in either a string (e.g. "id, name") or an array (e.g. ['id', 'name']). + * The method will automatically quote the column names unless a column contains some parenthesis + * (which means the column contains a DB expression). + * @return static the query object itself + * @see groupBy() + */ + public function addGroupBy($columns) + { + if (!is_array($columns)) { + $columns = preg_split('/\s*,\s*/', trim($columns), -1, PREG_SPLIT_NO_EMPTY); + } + if ($this->groupBy === null) { + $this->groupBy = $columns; + } else { + $this->groupBy = array_merge($this->groupBy, $columns); + } + return $this; + } + + /** + * Sets the ORDER BY part of the query. + * @param string|array $columns the columns (and the directions) to be ordered by. + * Columns can be specified in either a string (e.g. "id ASC, name DESC") or an array + * (e.g. `['id' => Query::SORT_ASC, 'name' => Query::SORT_DESC]`). + * The method will automatically quote the column names unless a column contains some parenthesis + * (which means the column contains a DB expression). + * @return static the query object itself + * @see addOrderBy() + */ + public function orderBy($columns) + { + $this->orderBy = $this->normalizeOrderBy($columns); + return $this; + } + + /** + * Adds additional ORDER BY columns to the query. + * @param string|array $columns the columns (and the directions) to be ordered by. + * Columns can be specified in either a string (e.g. "id ASC, name DESC") or an array + * (e.g. `['id' => Query::SORT_ASC, 'name' => Query::SORT_DESC]`). + * The method will automatically quote the column names unless a column contains some parenthesis + * (which means the column contains a DB expression). + * @return static the query object itself + * @see orderBy() + */ + 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; + } + + 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') ? self::SORT_ASC : self::SORT_DESC; + } else { + $result[$column] = self::SORT_ASC; + } + } + return $result; + } + } + + /** + * Sets the LIMIT part of the query. + * @param integer $limit the limit. Use null or negative value to disable limit. + * @return static the query object itself + */ + public function limit($limit) + { + $this->limit = $limit; + return $this; + } + + /** + * Sets the OFFSET part of the query. + * @param integer $offset the offset. Use null or negative value to disable offset. + * @return static the query object itself + */ + public function offset($offset) + { + $this->offset = $offset; + return $this; + } + + /** + * Sets the parameters to be bound to the query. + * @param array $params list of query parameter values indexed by parameter placeholders. + * For example, `[':name' => 'Dan', ':age' => 31]`. + * @return static the query object itself + * @see addParams() + */ + public function params($params) + { + $this->params = $params; + return $this; + } + + /** + * Adds additional parameters to be bound to the query. + * @param array $params list of query parameter values indexed by parameter placeholders. + * For example, `[':name' => 'Dan', ':age' => 31]`. + * @return static the query object itself + * @see params() + */ + public function addParams($params) + { + if (!empty($params)) { + if ($this->params === null) { + $this->params = $params; + } else { + foreach ($params as $name => $value) { + if (is_integer($name)) { + $this->params[] = $value; + } else { + $this->params[$name] = $value; + } + } + } + } + return $this; + } + + public function options(array $options) + { + $this->options = $options; + return $this; + } + + public function addOptions(array $options) + { + if (is_array($this->options)) { + $this->options = array_merge($this->options, $options); + } else { + $this->options = $options; + } + return $this; + } + + public function within($columns) + { + $this->within = $this->normalizeOrderBy($columns); + return $this; + } + + public function addWithin($columns) + { + $columns = $this->normalizeOrderBy($columns); + if ($this->within === null) { + $this->within = $columns; + } else { + $this->within = array_merge($this->within, $columns); + } + return $this; + } +} \ No newline at end of file diff --git a/extensions/sphinx/yii/sphinx/QueryBuilder.php b/extensions/sphinx/yii/sphinx/QueryBuilder.php new file mode 100644 index 0000000..e3f52de --- /dev/null +++ b/extensions/sphinx/yii/sphinx/QueryBuilder.php @@ -0,0 +1,75 @@ + + * @since 2.0 + */ +class QueryBuilder extends \yii\db\mysql\QueryBuilder +{ + /** + * Generates a SELECT SQL statement from a [[Query]] object. + * @param Query $query the [[Query]] object from which the SQL statement will be generated + * @return array the generated SQL statement (the first array element) and the corresponding + * parameters to be bound to the SQL statement (the second array element). + */ + public function build($query) + { + $params = $query->params; + $clauses = [ + $this->buildSelect($query->select, $query->distinct, $query->selectOption), + $this->buildFrom($query->from), + $this->buildWhere($query->where, $params), + $this->buildGroupBy($query->groupBy), + $this->buildWithin($query->within), + $this->buildOrderBy($query->orderBy), + $this->buildLimit($query->limit, $query->offset), + $this->buildOption($query->options), + ]; + return [implode($this->separator, array_filter($clauses)), $params]; + } + + /** + * @param array $columns + * @return string the ORDER BY clause built from [[query]]. + */ + public function buildWithin($columns) + { + if (empty($columns)) { + return ''; + } + $orders = []; + foreach ($columns as $name => $direction) { + if (is_object($direction)) { + $orders[] = (string)$direction; + } else { + $orders[] = $this->db->quoteColumnName($name) . ($direction === Query::SORT_DESC ? ' DESC' : ''); + } + } + return 'WITHIN GROUP ORDER BY ' . implode(', ', $orders); + } + + /** + * @param array $options + * @return string the OPTION clause build from [[query]] + */ + public function buildOption(array $options) + { + if (empty($options)) { + return ''; + } + $optionLines = []; + foreach ($options as $name => $value) { + $optionLines[] = $name . ' = ' . $value; + } + return 'OPTION ' . implode(', ', $optionLines); + } +} \ No newline at end of file diff --git a/extensions/sphinx/yii/sphinx/Schema.php b/extensions/sphinx/yii/sphinx/Schema.php index 14d0772..628f62f 100644 --- a/extensions/sphinx/yii/sphinx/Schema.php +++ b/extensions/sphinx/yii/sphinx/Schema.php @@ -15,5 +15,13 @@ namespace yii\sphinx; */ class Schema extends \yii\db\mysql\Schema { - // + /** + * Creates a query builder for the database. + * This method may be overridden by child classes to create a DBMS-specific query builder. + * @return QueryBuilder query builder instance + */ + public function createQueryBuilder() + { + return new QueryBuilder($this->db); + } } \ No newline at end of file diff --git a/tests/unit/data/sphinx/sphinx.conf b/tests/unit/data/sphinx/sphinx.conf index 6f61583..8623245 100644 --- a/tests/unit/data/sphinx/sphinx.conf +++ b/tests/unit/data/sphinx/sphinx.conf @@ -17,6 +17,7 @@ source yii2_test_article_src FROM yii2_test_article sql_attr_uint = id + sql_attr_uint = author_id sql_attr_timestamp = add_date sql_query_info = SELECT * FROM yii2_test_article WHERE id=$id @@ -38,6 +39,8 @@ source yii2_test_item_src FROM yii2_test_item sql_attr_uint = id + sql_attr_uint = category_id + sql_attr_float = price sql_attr_timestamp = add_date sql_query_info = SELECT * FROM yii2_test_item WHERE id=$id diff --git a/tests/unit/data/sphinx/sphinx.sql b/tests/unit/data/sphinx/sphinx.sql index a960e50..b6a2ef7 100644 --- a/tests/unit/data/sphinx/sphinx.sql +++ b/tests/unit/data/sphinx/sphinx.sql @@ -1,31 +1,6 @@ --- phpMyAdmin SQL Dump --- version 3.5.0 --- http://www.phpmyadmin.net --- --- Host: 10.10.50.201 --- Generation Time: Nov 01, 2013 at 04:38 PM --- Server version: 5.1.69-log --- PHP Version: 5.3.3 - SET SQL_MODE="NO_AUTO_VALUE_ON_ZERO"; SET time_zone = "+00:00"; - -/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; -/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */; -/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */; -/*!40101 SET NAMES utf8 */; - --- --- Database: `yiiexample` --- - --- -------------------------------------------------------- - --- --- Table structure for table `yii2_test_article` --- - CREATE TABLE IF NOT EXISTS `yii2_test_article` ( `id` int(11) NOT NULL AUTO_INCREMENT, `title` varchar(255) NOT NULL, @@ -35,20 +10,10 @@ CREATE TABLE IF NOT EXISTS `yii2_test_article` ( PRIMARY KEY (`id`) ) ENGINE=MyISAM DEFAULT CHARSET=utf8 AUTO_INCREMENT=3 ; --- --- Dumping data for table `yii2_test_article` --- - INSERT INTO `yii2_test_article` (`id`, `title`, `content`, `author_id`, `create_date`) VALUES (1, 'About cats', 'This article is about cats', 1, '2013-10-23 00:00:00'), (2, 'About dogs', 'This article is about dogs', 2, '2013-11-15 00:00:00'); --- -------------------------------------------------------- - --- --- Table structure for table `yii2_test_item` --- - CREATE TABLE IF NOT EXISTS `yii2_test_item` ( `id` int(11) NOT NULL AUTO_INCREMENT, `name` varchar(255) NOT NULL, @@ -58,14 +23,6 @@ CREATE TABLE IF NOT EXISTS `yii2_test_item` ( PRIMARY KEY (`id`) ) ENGINE=MyISAM DEFAULT CHARSET=utf8 AUTO_INCREMENT=3 ; --- --- Dumping data for table `yii2_test_item` --- - INSERT INTO `yii2_test_item` (`id`, `name`, `description`, `category_id`, `price`) VALUES (1, 'pencil', 'Simple pencil', 1, 2.5), (2, 'table', 'Wooden table', 2, 100); - -/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */; -/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */; -/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; diff --git a/tests/unit/extensions/sphinx/CommandTest.php b/tests/unit/extensions/sphinx/CommandTest.php new file mode 100644 index 0000000..013b130 --- /dev/null +++ b/tests/unit/extensions/sphinx/CommandTest.php @@ -0,0 +1,85 @@ +getConnection(); + + $sql = 'SELECT COUNT(*) FROM yii2_test_item_index WHERE MATCH(\'wooden\')'; + $command = $db->createCommand($sql); + $this->assertEquals(1, $command->queryScalar()); + + $command = $db->createCommand('bad SQL'); + $this->setExpectedException('\yii\db\Exception'); + $command->execute(); + } + + public function testQuery() + { + $db = $this->getConnection(); + + // query + $sql = 'SELECT * FROM yii2_test_item_index'; + $reader = $db->createCommand($sql)->query(); + $this->assertTrue($reader instanceof DataReader); + + // queryAll + $rows = $db->createCommand('SELECT * FROM yii2_test_item_index')->queryAll(); + $this->assertEquals(2, count($rows)); + $row = $rows[1]; + $this->assertEquals(2, $row['id']); + $this->assertEquals(2, $row['category_id']); + + $rows = $db->createCommand('SELECT * FROM yii2_test_item_index WHERE id=10')->queryAll(); + $this->assertEquals([], $rows); + + // queryOne + $sql = 'SELECT * FROM yii2_test_item_index ORDER BY id ASC'; + $row = $db->createCommand($sql)->queryOne(); + $this->assertEquals(1, $row['id']); + $this->assertEquals(1, $row['category_id']); + + $sql = 'SELECT * FROM yii2_test_item_index ORDER BY id ASC'; + $command = $db->createCommand($sql); + $command->prepare(); + $row = $command->queryOne(); + $this->assertEquals(1, $row['id']); + $this->assertEquals(1, $row['category_id']); + + $sql = 'SELECT * FROM yii2_test_item_index WHERE id=10'; + $command = $db->createCommand($sql); + $this->assertFalse($command->queryOne()); + + // queryColumn + $sql = 'SELECT * FROM yii2_test_item_index'; + $column = $db->createCommand($sql)->queryColumn(); + $this->assertEquals(range(1, 2), $column); + + $command = $db->createCommand('SELECT id FROM yii2_test_item_index WHERE id=10'); + $this->assertEquals([], $command->queryColumn()); + + // queryScalar + $sql = 'SELECT * FROM yii2_test_item_index ORDER BY id ASC'; + $this->assertEquals($db->createCommand($sql)->queryScalar(), 1); + + $sql = 'SELECT id FROM yii2_test_item_index ORDER BY id ASC'; + $command = $db->createCommand($sql); + $command->prepare(); + $this->assertEquals(1, $command->queryScalar()); + + $command = $db->createCommand('SELECT id FROM yii2_test_item_index WHERE id=10'); + $this->assertFalse($command->queryScalar()); + + $command = $db->createCommand('bad SQL'); + $this->setExpectedException('\yii\db\Exception'); + $command->query(); + } +} \ No newline at end of file From aa347f1e4c0ce428d1b1a0eb7e5fa0cc2e0df8c2 Mon Sep 17 00:00:00 2001 From: Paul Klimov Date: Fri, 8 Nov 2013 11:02:10 +0200 Subject: [PATCH 04/59] Merge branch 'master' of github.com:yiisoft/yii2 into sphinx --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 99ff95a..000ffe8 100644 --- a/.travis.yml +++ b/.travis.yml @@ -18,7 +18,7 @@ before_script: - tests/unit/data/travis/cubrid-setup.sh script: - - phpunit --coverage-clover tests/unit/runtime/coveralls/clover.xml --verbose --exclude-group mssql,oci,wincache,xcache,zenddata,vendor + - phpunit --coverage-clover tests/unit/runtime/coveralls/clover.xml --verbose --exclude-group mssql,oci,wincache,xcache,zenddata,vendor,sphinx after_script: - php vendor/bin/coveralls From fe3f7f2ddeb76257fb25bde487e8da5ead813474 Mon Sep 17 00:00:00 2001 From: Paul Klimov Date: Mon, 11 Nov 2013 11:30:35 +0200 Subject: [PATCH 05/59] "Sphinx" extension files layout recomposed. --- extensions/sphinx/Command.php | 19 + extensions/sphinx/Connection.php | 25 ++ extensions/sphinx/Query.php | 537 ++++++++++++++++++++++++ extensions/sphinx/QueryBuilder.php | 75 ++++ extensions/sphinx/Schema.php | 27 ++ extensions/sphinx/yii/sphinx/Command.php | 19 - extensions/sphinx/yii/sphinx/Connection.php | 25 -- extensions/sphinx/yii/sphinx/Query.php | 537 ------------------------ extensions/sphinx/yii/sphinx/QueryBuilder.php | 75 ---- extensions/sphinx/yii/sphinx/Schema.php | 27 -- tests/unit/extensions/sphinx/SphinxTestCase.php | 2 +- 11 files changed, 684 insertions(+), 684 deletions(-) create mode 100644 extensions/sphinx/Command.php create mode 100644 extensions/sphinx/Connection.php create mode 100644 extensions/sphinx/Query.php create mode 100644 extensions/sphinx/QueryBuilder.php create mode 100644 extensions/sphinx/Schema.php delete mode 100644 extensions/sphinx/yii/sphinx/Command.php delete mode 100644 extensions/sphinx/yii/sphinx/Connection.php delete mode 100644 extensions/sphinx/yii/sphinx/Query.php delete mode 100644 extensions/sphinx/yii/sphinx/QueryBuilder.php delete mode 100644 extensions/sphinx/yii/sphinx/Schema.php diff --git a/extensions/sphinx/Command.php b/extensions/sphinx/Command.php new file mode 100644 index 0000000..b457b9a --- /dev/null +++ b/extensions/sphinx/Command.php @@ -0,0 +1,19 @@ + + * @since 2.0 + */ +class Command extends \yii\db\Command +{ + // +} \ No newline at end of file diff --git a/extensions/sphinx/Connection.php b/extensions/sphinx/Connection.php new file mode 100644 index 0000000..a36afb9 --- /dev/null +++ b/extensions/sphinx/Connection.php @@ -0,0 +1,25 @@ + + * @since 2.0 + */ +class Connection extends \yii\db\Connection +{ + /** + * @inheritdoc + */ + public $schemaMap = [ + 'mysqli' => 'yii\sphinx\Schema', // MySQL + 'mysql' => 'yii\sphinx\Schema', // MySQL + ]; +} \ No newline at end of file diff --git a/extensions/sphinx/Query.php b/extensions/sphinx/Query.php new file mode 100644 index 0000000..0d76355 --- /dev/null +++ b/extensions/sphinx/Query.php @@ -0,0 +1,537 @@ + + * @since 2.0 + */ +class Query extends Component +{ + /** + * Sort ascending + * @see orderBy + */ + const SORT_ASC = false; + /** + * Sort descending + * @see orderBy + */ + const SORT_DESC = true; + + public $select; + /** + * @var string additional option that should be appended to the 'SELECT' keyword. For example, + * in MySQL, the option 'SQL_CALC_FOUND_ROWS' can be used. + */ + public $selectOption; + /** + * @var boolean whether to select distinct rows of data only. If this is set true, + * the SELECT clause would be changed to SELECT DISTINCT. + */ + public $distinct; + public $from; + public $where; + public $limit; + public $offset; + public $orderBy; + public $groupBy; + /** + * @var string WITHIN GROUP ORDER BY clause. This is a Sphinx specific extension + * that lets you control how the best row within a group will to be selected. + */ + public $within; + /** + * @var array per-query options in format: optionName => optionValue + * They will compose OPTION clause. This is a Sphinx specific extension + * that lets you control a number of per-query options. + */ + public $options; + /** + * @var array list of query parameter values indexed by parameter placeholders. + * For example, `[':name' => 'Dan', ':age' => 31]`. + */ + public $params; + /** + * @var string|callable $column the name of the column by which the query results should be indexed by. + * This can also be a callable (e.g. anonymous function) that returns the index value based on the given + * row data. For more details, see [[indexBy()]]. This property is only used by [[all()]]. + */ + public $indexBy; + + /** + * Creates a Sphinx command that can be used to execute this query. + * @param Connection $sphinxConnection the Sphinx connection used to generate the SQL statement. + * If this parameter is not given, the `sphinx` application component will be used. + * @return Command the created Sphinx command instance. + */ + public function createCommand($sphinxConnection = null) + { + if ($sphinxConnection === null) { + $sphinxConnection = Yii::$app->getComponent('sphinx'); + } + list ($sql, $params) = $sphinxConnection->getQueryBuilder()->build($this); + return $sphinxConnection->createCommand($sql, $params); + } + + /** + * Sets the [[indexBy]] property. + * @param string|callable $column the name of the column by which the query results should be indexed by. + * This can also be a callable (e.g. anonymous function) that returns the index value based on the given + * row data. The signature of the callable should be: + * + * ~~~ + * function ($row) + * { + * // return the index value corresponding to $row + * } + * ~~~ + * + * @return static the query object itself + */ + public function indexBy($column) + { + $this->indexBy = $column; + return $this; + } + + /** + * Executes the query and returns all results as an array. + * @param Connection $db the database connection used to generate the SQL statement. + * If this parameter is not given, the `db` application component will be used. + * @return array the query results. If the query results in nothing, an empty array will be returned. + */ + public function all($db = null) + { + $rows = $this->createCommand($db)->queryAll(); + 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; + } + + /** + * Executes the query and returns a single row of result. + * @param Connection $db the database connection used to generate the SQL statement. + * If this parameter is not given, the `db` application component will be used. + * @return array|boolean the first row (in terms of an array) of the query result. False is returned if the query + * results in nothing. + */ + public function one($db = null) + { + return $this->createCommand($db)->queryOne(); + } + + /** + * Returns the query result as a scalar value. + * The value returned will be the first column in the first row of the query results. + * @param Connection $db the database connection used to generate the SQL statement. + * If this parameter is not given, the `db` application component will be used. + * @return string|boolean the value of the first column in the first row of the query result. + * False is returned if the query result is empty. + */ + public function scalar($db = null) + { + return $this->createCommand($db)->queryScalar(); + } + + /** + * Executes the query and returns the first column of the result. + * @param Connection $db the database connection used to generate the SQL statement. + * If this parameter is not given, the `db` application component will be used. + * @return array the first column of the query result. An empty array is returned if the query results in nothing. + */ + public function column($db = null) + { + return $this->createCommand($db)->queryColumn(); + } + + /** + * Returns the number of records. + * @param string $q the COUNT expression. Defaults to '*'. + * Make sure you properly quote column names in the expression. + * @param Connection $db the database connection used to generate the SQL statement. + * If this parameter is not given, the `db` application component will be used. + * @return integer number of records + */ + public function count($q = '*', $db = null) + { + $this->select = ["COUNT($q)"]; + return $this->createCommand($db)->queryScalar(); + } + + /** + * Returns the sum of the specified column values. + * @param string $q the column name or expression. + * Make sure you properly quote column names in the expression. + * @param Connection $db the database connection used to generate the SQL statement. + * If this parameter is not given, the `db` application component will be used. + * @return integer the sum of the specified column values + */ + public function sum($q, $db = null) + { + $this->select = ["SUM($q)"]; + return $this->createCommand($db)->queryScalar(); + } + + /** + * Returns the average of the specified column values. + * @param string $q the column name or expression. + * Make sure you properly quote column names in the expression. + * @param Connection $db the database connection used to generate the SQL statement. + * If this parameter is not given, the `db` application component will be used. + * @return integer the average of the specified column values. + */ + public function average($q, $db = null) + { + $this->select = ["AVG($q)"]; + return $this->createCommand($db)->queryScalar(); + } + + /** + * Returns the minimum of the specified column values. + * @param string $q the column name or expression. + * Make sure you properly quote column names in the expression. + * @param Connection $db the database connection used to generate the SQL statement. + * If this parameter is not given, the `db` application component will be used. + * @return integer the minimum of the specified column values. + */ + public function min($q, $db = null) + { + $this->select = ["MIN($q)"]; + return $this->createCommand($db)->queryScalar(); + } + + /** + * Returns the maximum of the specified column values. + * @param string $q the column name or expression. + * Make sure you properly quote column names in the expression. + * @param Connection $db the database connection used to generate the SQL statement. + * If this parameter is not given, the `db` application component will be used. + * @return integer the maximum of the specified column values. + */ + public function max($q, $db = null) + { + $this->select = ["MAX($q)"]; + return $this->createCommand($db)->queryScalar(); + } + + /** + * Returns a value indicating whether the query result contains any row of data. + * @param Connection $db the database connection used to generate the SQL statement. + * If this parameter is not given, the `db` application component will be used. + * @return boolean whether the query result contains any row of data. + */ + public function exists($db = null) + { + $this->select = [new Expression('1')]; + return $this->scalar($db) !== false; + } + + /** + * Sets the SELECT part of the query. + * @param string|array $columns the columns to be selected. + * Columns can be specified in either a string (e.g. "id, name") or an array (e.g. ['id', 'name']). + * Columns can contain table prefixes (e.g. "tbl_user.id") and/or column aliases (e.g. "tbl_user.id AS user_id"). + * The method will automatically quote the column names unless a column contains some parenthesis + * (which means the column contains a DB expression). + * @param string $option additional option that should be appended to the 'SELECT' keyword. For example, + * in MySQL, the option 'SQL_CALC_FOUND_ROWS' can be used. + * @return static the query object itself + */ + public function select($columns, $option = null) + { + if (!is_array($columns)) { + $columns = preg_split('/\s*,\s*/', trim($columns), -1, PREG_SPLIT_NO_EMPTY); + } + $this->select = $columns; + $this->selectOption = $option; + return $this; + } + + /** + * Sets the value indicating whether to SELECT DISTINCT or not. + * @param bool $value whether to SELECT DISTINCT or not. + * @return static the query object itself + */ + public function distinct($value = true) + { + $this->distinct = $value; + return $this; + } + + /** + * Sets the FROM part of the query. + * @param string|array $tables the table(s) to be selected from. This can be either a string (e.g. `'tbl_user'`) + * or an array (e.g. `['tbl_user', 'tbl_profile']`) specifying one or several table names. + * Table names can contain schema prefixes (e.g. `'public.tbl_user'`) and/or table aliases (e.g. `'tbl_user u'`). + * The method will automatically quote the table names unless it contains some parenthesis + * (which means the table is given as a sub-query or DB expression). + * @return static the query object itself + */ + public function from($tables) + { + if (!is_array($tables)) { + $tables = preg_split('/\s*,\s*/', trim($tables), -1, PREG_SPLIT_NO_EMPTY); + } + $this->from = $tables; + return $this; + } + + public function where($condition, $params = []) + { + $this->where = $condition; + $this->addParams($params); + return $this; + } + + /** + * Adds an additional WHERE condition to the existing one. + * The new condition and the existing one will be joined using the 'AND' operator. + * @param string|array $condition the new WHERE condition. Please refer to [[where()]] + * on how to specify this parameter. + * @param array $params the parameters (name => value) to be bound to the query. + * @return static the query object itself + * @see where() + * @see orWhere() + */ + public function andWhere($condition, $params = []) + { + if ($this->where === null) { + $this->where = $condition; + } else { + $this->where = ['and', $this->where, $condition]; + } + $this->addParams($params); + return $this; + } + + /** + * Adds an additional WHERE condition to the existing one. + * The new condition and the existing one will be joined using the 'OR' operator. + * @param string|array $condition the new WHERE condition. Please refer to [[where()]] + * on how to specify this parameter. + * @param array $params the parameters (name => value) to be bound to the query. + * @return static the query object itself + * @see where() + * @see andWhere() + */ + public function orWhere($condition, $params = []) + { + if ($this->where === null) { + $this->where = $condition; + } else { + $this->where = ['or', $this->where, $condition]; + } + $this->addParams($params); + return $this; + } + + /** + * Sets the GROUP BY part of the query. + * @param string|array $columns the columns to be grouped by. + * Columns can be specified in either a string (e.g. "id, name") or an array (e.g. ['id', 'name']). + * The method will automatically quote the column names unless a column contains some parenthesis + * (which means the column contains a DB expression). + * @return static the query object itself + * @see addGroupBy() + */ + public function groupBy($columns) + { + if (!is_array($columns)) { + $columns = preg_split('/\s*,\s*/', trim($columns), -1, PREG_SPLIT_NO_EMPTY); + } + $this->groupBy = $columns; + return $this; + } + + /** + * Adds additional group-by columns to the existing ones. + * @param string|array $columns additional columns to be grouped by. + * Columns can be specified in either a string (e.g. "id, name") or an array (e.g. ['id', 'name']). + * The method will automatically quote the column names unless a column contains some parenthesis + * (which means the column contains a DB expression). + * @return static the query object itself + * @see groupBy() + */ + public function addGroupBy($columns) + { + if (!is_array($columns)) { + $columns = preg_split('/\s*,\s*/', trim($columns), -1, PREG_SPLIT_NO_EMPTY); + } + if ($this->groupBy === null) { + $this->groupBy = $columns; + } else { + $this->groupBy = array_merge($this->groupBy, $columns); + } + return $this; + } + + /** + * Sets the ORDER BY part of the query. + * @param string|array $columns the columns (and the directions) to be ordered by. + * Columns can be specified in either a string (e.g. "id ASC, name DESC") or an array + * (e.g. `['id' => Query::SORT_ASC, 'name' => Query::SORT_DESC]`). + * The method will automatically quote the column names unless a column contains some parenthesis + * (which means the column contains a DB expression). + * @return static the query object itself + * @see addOrderBy() + */ + public function orderBy($columns) + { + $this->orderBy = $this->normalizeOrderBy($columns); + return $this; + } + + /** + * Adds additional ORDER BY columns to the query. + * @param string|array $columns the columns (and the directions) to be ordered by. + * Columns can be specified in either a string (e.g. "id ASC, name DESC") or an array + * (e.g. `['id' => Query::SORT_ASC, 'name' => Query::SORT_DESC]`). + * The method will automatically quote the column names unless a column contains some parenthesis + * (which means the column contains a DB expression). + * @return static the query object itself + * @see orderBy() + */ + 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; + } + + 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') ? self::SORT_ASC : self::SORT_DESC; + } else { + $result[$column] = self::SORT_ASC; + } + } + return $result; + } + } + + /** + * Sets the LIMIT part of the query. + * @param integer $limit the limit. Use null or negative value to disable limit. + * @return static the query object itself + */ + public function limit($limit) + { + $this->limit = $limit; + return $this; + } + + /** + * Sets the OFFSET part of the query. + * @param integer $offset the offset. Use null or negative value to disable offset. + * @return static the query object itself + */ + public function offset($offset) + { + $this->offset = $offset; + return $this; + } + + /** + * Sets the parameters to be bound to the query. + * @param array $params list of query parameter values indexed by parameter placeholders. + * For example, `[':name' => 'Dan', ':age' => 31]`. + * @return static the query object itself + * @see addParams() + */ + public function params($params) + { + $this->params = $params; + return $this; + } + + /** + * Adds additional parameters to be bound to the query. + * @param array $params list of query parameter values indexed by parameter placeholders. + * For example, `[':name' => 'Dan', ':age' => 31]`. + * @return static the query object itself + * @see params() + */ + public function addParams($params) + { + if (!empty($params)) { + if ($this->params === null) { + $this->params = $params; + } else { + foreach ($params as $name => $value) { + if (is_integer($name)) { + $this->params[] = $value; + } else { + $this->params[$name] = $value; + } + } + } + } + return $this; + } + + public function options(array $options) + { + $this->options = $options; + return $this; + } + + public function addOptions(array $options) + { + if (is_array($this->options)) { + $this->options = array_merge($this->options, $options); + } else { + $this->options = $options; + } + return $this; + } + + public function within($columns) + { + $this->within = $this->normalizeOrderBy($columns); + return $this; + } + + public function addWithin($columns) + { + $columns = $this->normalizeOrderBy($columns); + if ($this->within === null) { + $this->within = $columns; + } else { + $this->within = array_merge($this->within, $columns); + } + return $this; + } +} \ No newline at end of file diff --git a/extensions/sphinx/QueryBuilder.php b/extensions/sphinx/QueryBuilder.php new file mode 100644 index 0000000..e3f52de --- /dev/null +++ b/extensions/sphinx/QueryBuilder.php @@ -0,0 +1,75 @@ + + * @since 2.0 + */ +class QueryBuilder extends \yii\db\mysql\QueryBuilder +{ + /** + * Generates a SELECT SQL statement from a [[Query]] object. + * @param Query $query the [[Query]] object from which the SQL statement will be generated + * @return array the generated SQL statement (the first array element) and the corresponding + * parameters to be bound to the SQL statement (the second array element). + */ + public function build($query) + { + $params = $query->params; + $clauses = [ + $this->buildSelect($query->select, $query->distinct, $query->selectOption), + $this->buildFrom($query->from), + $this->buildWhere($query->where, $params), + $this->buildGroupBy($query->groupBy), + $this->buildWithin($query->within), + $this->buildOrderBy($query->orderBy), + $this->buildLimit($query->limit, $query->offset), + $this->buildOption($query->options), + ]; + return [implode($this->separator, array_filter($clauses)), $params]; + } + + /** + * @param array $columns + * @return string the ORDER BY clause built from [[query]]. + */ + public function buildWithin($columns) + { + if (empty($columns)) { + return ''; + } + $orders = []; + foreach ($columns as $name => $direction) { + if (is_object($direction)) { + $orders[] = (string)$direction; + } else { + $orders[] = $this->db->quoteColumnName($name) . ($direction === Query::SORT_DESC ? ' DESC' : ''); + } + } + return 'WITHIN GROUP ORDER BY ' . implode(', ', $orders); + } + + /** + * @param array $options + * @return string the OPTION clause build from [[query]] + */ + public function buildOption(array $options) + { + if (empty($options)) { + return ''; + } + $optionLines = []; + foreach ($options as $name => $value) { + $optionLines[] = $name . ' = ' . $value; + } + return 'OPTION ' . implode(', ', $optionLines); + } +} \ No newline at end of file diff --git a/extensions/sphinx/Schema.php b/extensions/sphinx/Schema.php new file mode 100644 index 0000000..628f62f --- /dev/null +++ b/extensions/sphinx/Schema.php @@ -0,0 +1,27 @@ + + * @since 2.0 + */ +class Schema extends \yii\db\mysql\Schema +{ + /** + * Creates a query builder for the database. + * This method may be overridden by child classes to create a DBMS-specific query builder. + * @return QueryBuilder query builder instance + */ + public function createQueryBuilder() + { + return new QueryBuilder($this->db); + } +} \ No newline at end of file diff --git a/extensions/sphinx/yii/sphinx/Command.php b/extensions/sphinx/yii/sphinx/Command.php deleted file mode 100644 index b457b9a..0000000 --- a/extensions/sphinx/yii/sphinx/Command.php +++ /dev/null @@ -1,19 +0,0 @@ - - * @since 2.0 - */ -class Command extends \yii\db\Command -{ - // -} \ No newline at end of file diff --git a/extensions/sphinx/yii/sphinx/Connection.php b/extensions/sphinx/yii/sphinx/Connection.php deleted file mode 100644 index a36afb9..0000000 --- a/extensions/sphinx/yii/sphinx/Connection.php +++ /dev/null @@ -1,25 +0,0 @@ - - * @since 2.0 - */ -class Connection extends \yii\db\Connection -{ - /** - * @inheritdoc - */ - public $schemaMap = [ - 'mysqli' => 'yii\sphinx\Schema', // MySQL - 'mysql' => 'yii\sphinx\Schema', // MySQL - ]; -} \ No newline at end of file diff --git a/extensions/sphinx/yii/sphinx/Query.php b/extensions/sphinx/yii/sphinx/Query.php deleted file mode 100644 index 0d76355..0000000 --- a/extensions/sphinx/yii/sphinx/Query.php +++ /dev/null @@ -1,537 +0,0 @@ - - * @since 2.0 - */ -class Query extends Component -{ - /** - * Sort ascending - * @see orderBy - */ - const SORT_ASC = false; - /** - * Sort descending - * @see orderBy - */ - const SORT_DESC = true; - - public $select; - /** - * @var string additional option that should be appended to the 'SELECT' keyword. For example, - * in MySQL, the option 'SQL_CALC_FOUND_ROWS' can be used. - */ - public $selectOption; - /** - * @var boolean whether to select distinct rows of data only. If this is set true, - * the SELECT clause would be changed to SELECT DISTINCT. - */ - public $distinct; - public $from; - public $where; - public $limit; - public $offset; - public $orderBy; - public $groupBy; - /** - * @var string WITHIN GROUP ORDER BY clause. This is a Sphinx specific extension - * that lets you control how the best row within a group will to be selected. - */ - public $within; - /** - * @var array per-query options in format: optionName => optionValue - * They will compose OPTION clause. This is a Sphinx specific extension - * that lets you control a number of per-query options. - */ - public $options; - /** - * @var array list of query parameter values indexed by parameter placeholders. - * For example, `[':name' => 'Dan', ':age' => 31]`. - */ - public $params; - /** - * @var string|callable $column the name of the column by which the query results should be indexed by. - * This can also be a callable (e.g. anonymous function) that returns the index value based on the given - * row data. For more details, see [[indexBy()]]. This property is only used by [[all()]]. - */ - public $indexBy; - - /** - * Creates a Sphinx command that can be used to execute this query. - * @param Connection $sphinxConnection the Sphinx connection used to generate the SQL statement. - * If this parameter is not given, the `sphinx` application component will be used. - * @return Command the created Sphinx command instance. - */ - public function createCommand($sphinxConnection = null) - { - if ($sphinxConnection === null) { - $sphinxConnection = Yii::$app->getComponent('sphinx'); - } - list ($sql, $params) = $sphinxConnection->getQueryBuilder()->build($this); - return $sphinxConnection->createCommand($sql, $params); - } - - /** - * Sets the [[indexBy]] property. - * @param string|callable $column the name of the column by which the query results should be indexed by. - * This can also be a callable (e.g. anonymous function) that returns the index value based on the given - * row data. The signature of the callable should be: - * - * ~~~ - * function ($row) - * { - * // return the index value corresponding to $row - * } - * ~~~ - * - * @return static the query object itself - */ - public function indexBy($column) - { - $this->indexBy = $column; - return $this; - } - - /** - * Executes the query and returns all results as an array. - * @param Connection $db the database connection used to generate the SQL statement. - * If this parameter is not given, the `db` application component will be used. - * @return array the query results. If the query results in nothing, an empty array will be returned. - */ - public function all($db = null) - { - $rows = $this->createCommand($db)->queryAll(); - 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; - } - - /** - * Executes the query and returns a single row of result. - * @param Connection $db the database connection used to generate the SQL statement. - * If this parameter is not given, the `db` application component will be used. - * @return array|boolean the first row (in terms of an array) of the query result. False is returned if the query - * results in nothing. - */ - public function one($db = null) - { - return $this->createCommand($db)->queryOne(); - } - - /** - * Returns the query result as a scalar value. - * The value returned will be the first column in the first row of the query results. - * @param Connection $db the database connection used to generate the SQL statement. - * If this parameter is not given, the `db` application component will be used. - * @return string|boolean the value of the first column in the first row of the query result. - * False is returned if the query result is empty. - */ - public function scalar($db = null) - { - return $this->createCommand($db)->queryScalar(); - } - - /** - * Executes the query and returns the first column of the result. - * @param Connection $db the database connection used to generate the SQL statement. - * If this parameter is not given, the `db` application component will be used. - * @return array the first column of the query result. An empty array is returned if the query results in nothing. - */ - public function column($db = null) - { - return $this->createCommand($db)->queryColumn(); - } - - /** - * Returns the number of records. - * @param string $q the COUNT expression. Defaults to '*'. - * Make sure you properly quote column names in the expression. - * @param Connection $db the database connection used to generate the SQL statement. - * If this parameter is not given, the `db` application component will be used. - * @return integer number of records - */ - public function count($q = '*', $db = null) - { - $this->select = ["COUNT($q)"]; - return $this->createCommand($db)->queryScalar(); - } - - /** - * Returns the sum of the specified column values. - * @param string $q the column name or expression. - * Make sure you properly quote column names in the expression. - * @param Connection $db the database connection used to generate the SQL statement. - * If this parameter is not given, the `db` application component will be used. - * @return integer the sum of the specified column values - */ - public function sum($q, $db = null) - { - $this->select = ["SUM($q)"]; - return $this->createCommand($db)->queryScalar(); - } - - /** - * Returns the average of the specified column values. - * @param string $q the column name or expression. - * Make sure you properly quote column names in the expression. - * @param Connection $db the database connection used to generate the SQL statement. - * If this parameter is not given, the `db` application component will be used. - * @return integer the average of the specified column values. - */ - public function average($q, $db = null) - { - $this->select = ["AVG($q)"]; - return $this->createCommand($db)->queryScalar(); - } - - /** - * Returns the minimum of the specified column values. - * @param string $q the column name or expression. - * Make sure you properly quote column names in the expression. - * @param Connection $db the database connection used to generate the SQL statement. - * If this parameter is not given, the `db` application component will be used. - * @return integer the minimum of the specified column values. - */ - public function min($q, $db = null) - { - $this->select = ["MIN($q)"]; - return $this->createCommand($db)->queryScalar(); - } - - /** - * Returns the maximum of the specified column values. - * @param string $q the column name or expression. - * Make sure you properly quote column names in the expression. - * @param Connection $db the database connection used to generate the SQL statement. - * If this parameter is not given, the `db` application component will be used. - * @return integer the maximum of the specified column values. - */ - public function max($q, $db = null) - { - $this->select = ["MAX($q)"]; - return $this->createCommand($db)->queryScalar(); - } - - /** - * Returns a value indicating whether the query result contains any row of data. - * @param Connection $db the database connection used to generate the SQL statement. - * If this parameter is not given, the `db` application component will be used. - * @return boolean whether the query result contains any row of data. - */ - public function exists($db = null) - { - $this->select = [new Expression('1')]; - return $this->scalar($db) !== false; - } - - /** - * Sets the SELECT part of the query. - * @param string|array $columns the columns to be selected. - * Columns can be specified in either a string (e.g. "id, name") or an array (e.g. ['id', 'name']). - * Columns can contain table prefixes (e.g. "tbl_user.id") and/or column aliases (e.g. "tbl_user.id AS user_id"). - * The method will automatically quote the column names unless a column contains some parenthesis - * (which means the column contains a DB expression). - * @param string $option additional option that should be appended to the 'SELECT' keyword. For example, - * in MySQL, the option 'SQL_CALC_FOUND_ROWS' can be used. - * @return static the query object itself - */ - public function select($columns, $option = null) - { - if (!is_array($columns)) { - $columns = preg_split('/\s*,\s*/', trim($columns), -1, PREG_SPLIT_NO_EMPTY); - } - $this->select = $columns; - $this->selectOption = $option; - return $this; - } - - /** - * Sets the value indicating whether to SELECT DISTINCT or not. - * @param bool $value whether to SELECT DISTINCT or not. - * @return static the query object itself - */ - public function distinct($value = true) - { - $this->distinct = $value; - return $this; - } - - /** - * Sets the FROM part of the query. - * @param string|array $tables the table(s) to be selected from. This can be either a string (e.g. `'tbl_user'`) - * or an array (e.g. `['tbl_user', 'tbl_profile']`) specifying one or several table names. - * Table names can contain schema prefixes (e.g. `'public.tbl_user'`) and/or table aliases (e.g. `'tbl_user u'`). - * The method will automatically quote the table names unless it contains some parenthesis - * (which means the table is given as a sub-query or DB expression). - * @return static the query object itself - */ - public function from($tables) - { - if (!is_array($tables)) { - $tables = preg_split('/\s*,\s*/', trim($tables), -1, PREG_SPLIT_NO_EMPTY); - } - $this->from = $tables; - return $this; - } - - public function where($condition, $params = []) - { - $this->where = $condition; - $this->addParams($params); - return $this; - } - - /** - * Adds an additional WHERE condition to the existing one. - * The new condition and the existing one will be joined using the 'AND' operator. - * @param string|array $condition the new WHERE condition. Please refer to [[where()]] - * on how to specify this parameter. - * @param array $params the parameters (name => value) to be bound to the query. - * @return static the query object itself - * @see where() - * @see orWhere() - */ - public function andWhere($condition, $params = []) - { - if ($this->where === null) { - $this->where = $condition; - } else { - $this->where = ['and', $this->where, $condition]; - } - $this->addParams($params); - return $this; - } - - /** - * Adds an additional WHERE condition to the existing one. - * The new condition and the existing one will be joined using the 'OR' operator. - * @param string|array $condition the new WHERE condition. Please refer to [[where()]] - * on how to specify this parameter. - * @param array $params the parameters (name => value) to be bound to the query. - * @return static the query object itself - * @see where() - * @see andWhere() - */ - public function orWhere($condition, $params = []) - { - if ($this->where === null) { - $this->where = $condition; - } else { - $this->where = ['or', $this->where, $condition]; - } - $this->addParams($params); - return $this; - } - - /** - * Sets the GROUP BY part of the query. - * @param string|array $columns the columns to be grouped by. - * Columns can be specified in either a string (e.g. "id, name") or an array (e.g. ['id', 'name']). - * The method will automatically quote the column names unless a column contains some parenthesis - * (which means the column contains a DB expression). - * @return static the query object itself - * @see addGroupBy() - */ - public function groupBy($columns) - { - if (!is_array($columns)) { - $columns = preg_split('/\s*,\s*/', trim($columns), -1, PREG_SPLIT_NO_EMPTY); - } - $this->groupBy = $columns; - return $this; - } - - /** - * Adds additional group-by columns to the existing ones. - * @param string|array $columns additional columns to be grouped by. - * Columns can be specified in either a string (e.g. "id, name") or an array (e.g. ['id', 'name']). - * The method will automatically quote the column names unless a column contains some parenthesis - * (which means the column contains a DB expression). - * @return static the query object itself - * @see groupBy() - */ - public function addGroupBy($columns) - { - if (!is_array($columns)) { - $columns = preg_split('/\s*,\s*/', trim($columns), -1, PREG_SPLIT_NO_EMPTY); - } - if ($this->groupBy === null) { - $this->groupBy = $columns; - } else { - $this->groupBy = array_merge($this->groupBy, $columns); - } - return $this; - } - - /** - * Sets the ORDER BY part of the query. - * @param string|array $columns the columns (and the directions) to be ordered by. - * Columns can be specified in either a string (e.g. "id ASC, name DESC") or an array - * (e.g. `['id' => Query::SORT_ASC, 'name' => Query::SORT_DESC]`). - * The method will automatically quote the column names unless a column contains some parenthesis - * (which means the column contains a DB expression). - * @return static the query object itself - * @see addOrderBy() - */ - public function orderBy($columns) - { - $this->orderBy = $this->normalizeOrderBy($columns); - return $this; - } - - /** - * Adds additional ORDER BY columns to the query. - * @param string|array $columns the columns (and the directions) to be ordered by. - * Columns can be specified in either a string (e.g. "id ASC, name DESC") or an array - * (e.g. `['id' => Query::SORT_ASC, 'name' => Query::SORT_DESC]`). - * The method will automatically quote the column names unless a column contains some parenthesis - * (which means the column contains a DB expression). - * @return static the query object itself - * @see orderBy() - */ - 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; - } - - 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') ? self::SORT_ASC : self::SORT_DESC; - } else { - $result[$column] = self::SORT_ASC; - } - } - return $result; - } - } - - /** - * Sets the LIMIT part of the query. - * @param integer $limit the limit. Use null or negative value to disable limit. - * @return static the query object itself - */ - public function limit($limit) - { - $this->limit = $limit; - return $this; - } - - /** - * Sets the OFFSET part of the query. - * @param integer $offset the offset. Use null or negative value to disable offset. - * @return static the query object itself - */ - public function offset($offset) - { - $this->offset = $offset; - return $this; - } - - /** - * Sets the parameters to be bound to the query. - * @param array $params list of query parameter values indexed by parameter placeholders. - * For example, `[':name' => 'Dan', ':age' => 31]`. - * @return static the query object itself - * @see addParams() - */ - public function params($params) - { - $this->params = $params; - return $this; - } - - /** - * Adds additional parameters to be bound to the query. - * @param array $params list of query parameter values indexed by parameter placeholders. - * For example, `[':name' => 'Dan', ':age' => 31]`. - * @return static the query object itself - * @see params() - */ - public function addParams($params) - { - if (!empty($params)) { - if ($this->params === null) { - $this->params = $params; - } else { - foreach ($params as $name => $value) { - if (is_integer($name)) { - $this->params[] = $value; - } else { - $this->params[$name] = $value; - } - } - } - } - return $this; - } - - public function options(array $options) - { - $this->options = $options; - return $this; - } - - public function addOptions(array $options) - { - if (is_array($this->options)) { - $this->options = array_merge($this->options, $options); - } else { - $this->options = $options; - } - return $this; - } - - public function within($columns) - { - $this->within = $this->normalizeOrderBy($columns); - return $this; - } - - public function addWithin($columns) - { - $columns = $this->normalizeOrderBy($columns); - if ($this->within === null) { - $this->within = $columns; - } else { - $this->within = array_merge($this->within, $columns); - } - return $this; - } -} \ No newline at end of file diff --git a/extensions/sphinx/yii/sphinx/QueryBuilder.php b/extensions/sphinx/yii/sphinx/QueryBuilder.php deleted file mode 100644 index e3f52de..0000000 --- a/extensions/sphinx/yii/sphinx/QueryBuilder.php +++ /dev/null @@ -1,75 +0,0 @@ - - * @since 2.0 - */ -class QueryBuilder extends \yii\db\mysql\QueryBuilder -{ - /** - * Generates a SELECT SQL statement from a [[Query]] object. - * @param Query $query the [[Query]] object from which the SQL statement will be generated - * @return array the generated SQL statement (the first array element) and the corresponding - * parameters to be bound to the SQL statement (the second array element). - */ - public function build($query) - { - $params = $query->params; - $clauses = [ - $this->buildSelect($query->select, $query->distinct, $query->selectOption), - $this->buildFrom($query->from), - $this->buildWhere($query->where, $params), - $this->buildGroupBy($query->groupBy), - $this->buildWithin($query->within), - $this->buildOrderBy($query->orderBy), - $this->buildLimit($query->limit, $query->offset), - $this->buildOption($query->options), - ]; - return [implode($this->separator, array_filter($clauses)), $params]; - } - - /** - * @param array $columns - * @return string the ORDER BY clause built from [[query]]. - */ - public function buildWithin($columns) - { - if (empty($columns)) { - return ''; - } - $orders = []; - foreach ($columns as $name => $direction) { - if (is_object($direction)) { - $orders[] = (string)$direction; - } else { - $orders[] = $this->db->quoteColumnName($name) . ($direction === Query::SORT_DESC ? ' DESC' : ''); - } - } - return 'WITHIN GROUP ORDER BY ' . implode(', ', $orders); - } - - /** - * @param array $options - * @return string the OPTION clause build from [[query]] - */ - public function buildOption(array $options) - { - if (empty($options)) { - return ''; - } - $optionLines = []; - foreach ($options as $name => $value) { - $optionLines[] = $name . ' = ' . $value; - } - return 'OPTION ' . implode(', ', $optionLines); - } -} \ No newline at end of file diff --git a/extensions/sphinx/yii/sphinx/Schema.php b/extensions/sphinx/yii/sphinx/Schema.php deleted file mode 100644 index 628f62f..0000000 --- a/extensions/sphinx/yii/sphinx/Schema.php +++ /dev/null @@ -1,27 +0,0 @@ - - * @since 2.0 - */ -class Schema extends \yii\db\mysql\Schema -{ - /** - * Creates a query builder for the database. - * This method may be overridden by child classes to create a DBMS-specific query builder. - * @return QueryBuilder query builder instance - */ - public function createQueryBuilder() - { - return new QueryBuilder($this->db); - } -} \ No newline at end of file diff --git a/tests/unit/extensions/sphinx/SphinxTestCase.php b/tests/unit/extensions/sphinx/SphinxTestCase.php index c643c65..475ca69 100644 --- a/tests/unit/extensions/sphinx/SphinxTestCase.php +++ b/tests/unit/extensions/sphinx/SphinxTestCase.php @@ -56,7 +56,7 @@ class SphinxTestCase extends TestCase protected static function loadClassMap() { $baseNameSpace = 'yii/sphinx'; - $basePath = realpath(__DIR__. '/../../../../extensions/sphinx/yii/sphinx'); + $basePath = realpath(__DIR__. '/../../../../extensions/sphinx'); $files = FileHelper::findFiles($basePath); foreach ($files as $file) { $classRelativePath = str_replace($basePath, '', $file); From 0f94ed39b69f8e54db738b0c5a39b9655fb604f6 Mon Sep 17 00:00:00 2001 From: Paul Klimov Date: Mon, 11 Nov 2013 14:28:31 +0200 Subject: [PATCH 06/59] "Sphinx" unit tests updated to expose runtime indexes. --- extensions/sphinx/Schema.php | 88 +++++++++++++++++++++++++ tests/unit/data/sphinx/sphinx.conf | 14 +++- tests/unit/extensions/sphinx/CommandTest.php | 54 +++++++++++++++ tests/unit/extensions/sphinx/SphinxTestCase.php | 13 +++- 4 files changed, 166 insertions(+), 3 deletions(-) diff --git a/extensions/sphinx/Schema.php b/extensions/sphinx/Schema.php index 628f62f..ca05e38 100644 --- a/extensions/sphinx/Schema.php +++ b/extensions/sphinx/Schema.php @@ -7,6 +7,9 @@ namespace yii\sphinx; +use yii\db\ColumnSchema; +use yii\db\TableSchema; + /** * Class Schema * @@ -24,4 +27,89 @@ class Schema extends \yii\db\mysql\Schema { return new QueryBuilder($this->db); } + + /** + * Loads the metadata for the specified table. + * @param string $name table name + * @return TableSchema driver dependent table metadata. Null if the table does not exist. + */ + protected function loadTableSchema($name) + { + $table = new TableSchema; + $this->resolveTableNames($table, $name); + + if ($this->findColumns($table)) { + return $table; + } else { + return null; + } + } + + /** + * Collects the metadata of table columns. + * @param TableSchema $table the table metadata + * @return boolean whether the table exists in the database + * @throws \Exception if DB query fails + */ + protected function findColumns($table) + { + $sql = 'DESCRIBE ' . $this->quoteSimpleTableName($table->name); + try { + $columns = $this->db->createCommand($sql)->queryAll(); + } catch (\Exception $e) { + $previous = $e->getPrevious(); + if ($previous instanceof \PDOException && $previous->getCode() == '42S02') { + // index does not exist + return false; + } + throw $e; + } + foreach ($columns as $info) { + $column = $this->loadColumnSchema($info); + $table->columns[$column->name] = $column; + if ($column->isPrimaryKey) { + $table->primaryKey[] = $column->name; + if ($column->autoIncrement) { + $table->sequenceName = ''; + } + } + } + return true; + } + + /** + * Loads the column information into a [[ColumnSchema]] object. + * @param array $info column information + * @return ColumnSchema the column schema object + */ + protected function loadColumnSchema($info) + { + $column = new ColumnSchema; + + $column->name = $info['Field']; + // Not supported : + //$column->allowNull = $info['Null'] === 'YES'; + //$column->isPrimaryKey = strpos($info['Key'], 'PRI') !== false; + //$column->autoIncrement = stripos($info['Extra'], 'auto_increment') !== false; + //$column->comment = $info['Comment']; + + + $column->dbType = $info['Type']; + //$column->unsigned = strpos($column->dbType, 'unsigned') !== false; + + $type = $info['Type']; + if (isset($this->typeMap[$type])) { + $column->type = $this->typeMap[$type]; + } else { + $column->type = self::TYPE_STRING; + } + + $column->phpType = $this->getColumnPhpType($column); + + /*if ($column->type !== 'timestamp' || $info['Default'] !== 'CURRENT_TIMESTAMP') { + $column->defaultValue = $column->typecast($info['Default']); + }*/ + + return $column; + } } \ No newline at end of file diff --git a/tests/unit/data/sphinx/sphinx.conf b/tests/unit/data/sphinx/sphinx.conf index 8623245..fb28525 100644 --- a/tests/unit/data/sphinx/sphinx.conf +++ b/tests/unit/data/sphinx/sphinx.conf @@ -50,7 +50,7 @@ source yii2_test_item_src index yii2_test_article_index { source = yii2_test_article_src - path = /var/lib/sphinx/yii2_test_article_src + path = /var/lib/sphinx/yii2_test_article docinfo = extern charset_type = sbcs } @@ -59,12 +59,22 @@ index yii2_test_article_index index yii2_test_item_index { source = yii2_test_item_src - path = /var/lib/sphinx/yii2_test_item_src + path = /var/lib/sphinx/yii2_test_item docinfo = extern charset_type = sbcs } +index yii2_test_rt_index +{ + type = rt + path = /var/lib/sphinx/yii2_test_rt + rt_field = title + rt_field = content + rt_attr_uint = type_id +} + + indexer { mem_limit = 32M diff --git a/tests/unit/extensions/sphinx/CommandTest.php b/tests/unit/extensions/sphinx/CommandTest.php index 013b130..47fb5b9 100644 --- a/tests/unit/extensions/sphinx/CommandTest.php +++ b/tests/unit/extensions/sphinx/CommandTest.php @@ -9,6 +9,14 @@ use yii\db\DataReader; */ class CommandTest extends SphinxTestCase { + protected function tearDown() + { + $this->truncateRuntimeIndex('yii2_test_rt_index'); + parent::tearDown(); + } + + // Tests : + public function testExecute() { $db = $this->getConnection(); @@ -82,4 +90,50 @@ class CommandTest extends SphinxTestCase $this->setExpectedException('\yii\db\Exception'); $command->query(); } + + /** + * @depends testQuery + */ + public function testInsert() + { + $db = $this->getConnection(); + + $command = $db->createCommand()->insert('yii2_test_rt_index', [ + 'title' => 'Test title', + 'content' => 'Test content', + 'type_id' => 2, + 'id' => 1, + ]); + $this->assertEquals(1, $command->execute(), 'Unable to execute insert!'); + + $rows = $db->createCommand('SELECT * FROM yii2_test_rt_index')->queryAll(); + $this->assertEquals(1, count($rows), 'No row inserted!'); + } + + /** + * @depends testInsert + */ + public function testUpdate() + { + $db = $this->getConnection(); + + $db->createCommand()->insert('yii2_test_rt_index', [ + 'title' => 'Test title', + 'content' => 'Test content', + 'type_id' => 2, + 'id' => 1, + ])->execute(); + + $newTypeId = 5; + $command = $db->createCommand()->update( + 'yii2_test_rt_index', + [ + 'title' => 'Test title', + 'content' => 'Test content', + 'type_id' => $newTypeId, + ], + 'id = 1' + ); + $this->assertEquals(1, $command->execute(), 'Unable to execute update!'); + } } \ No newline at end of file diff --git a/tests/unit/extensions/sphinx/SphinxTestCase.php b/tests/unit/extensions/sphinx/SphinxTestCase.php index 475ca69..a77f987 100644 --- a/tests/unit/extensions/sphinx/SphinxTestCase.php +++ b/tests/unit/extensions/sphinx/SphinxTestCase.php @@ -75,7 +75,7 @@ class SphinxTestCase extends TestCase if (!$reset && $this->sphinx) { return $this->sphinx; } - $db = new \yii\db\Connection; + $db = new \yii\sphinx\Connection; $db->dsn = $this->sphinxConfig['dsn']; if (isset($this->sphinxConfig['username'])) { $db->username = $this->sphinxConfig['username']; @@ -98,4 +98,15 @@ class SphinxTestCase extends TestCase $this->sphinx = $db; return $db; } + + /** + * Truncates the runtime index. + * @param string $indexName index name. + */ + protected function truncateRuntimeIndex($indexName) + { + if ($this->sphinx) { + $this->sphinx->createCommand('TRUNCATE RTINDEX ' . $indexName)->execute(); + } + } } \ No newline at end of file From 6e3dfb8f681e2ca2146d140fb35a55e77972db88 Mon Sep 17 00:00:00 2001 From: Paul Klimov Date: Mon, 11 Nov 2013 16:14:27 +0200 Subject: [PATCH 07/59] "Sphinx" unit tests for "update" and "delete" added. --- extensions/sphinx/Schema.php | 17 +++++++++++++++++ tests/unit/extensions/sphinx/CommandTest.php | 27 +++++++++++++++++++++++++-- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/extensions/sphinx/Schema.php b/extensions/sphinx/Schema.php index ca05e38..5afb445 100644 --- a/extensions/sphinx/Schema.php +++ b/extensions/sphinx/Schema.php @@ -19,6 +19,23 @@ use yii\db\TableSchema; class Schema extends \yii\db\mysql\Schema { /** + * @var array mapping from physical column types (keys) to abstract column types (values) + */ + public $typeMap = [ + 'field' => self::TYPE_STRING, + 'string' => self::TYPE_STRING, + 'ordinal' => self::TYPE_STRING, + 'integer' => self::TYPE_INTEGER, + 'int' => self::TYPE_INTEGER, + 'uint' => self::TYPE_INTEGER, + 'bigint' => self::TYPE_BIGINT, + 'timestamp' => self::TYPE_TIMESTAMP, + 'bool' => self::TYPE_BOOLEAN, + 'float' => self::TYPE_FLOAT, + 'mva' => self::TYPE_STRING, + ]; + + /** * Creates a query builder for the database. * This method may be overridden by child classes to create a DBMS-specific query builder. * @return QueryBuilder query builder instance diff --git a/tests/unit/extensions/sphinx/CommandTest.php b/tests/unit/extensions/sphinx/CommandTest.php index 47fb5b9..639dea2 100644 --- a/tests/unit/extensions/sphinx/CommandTest.php +++ b/tests/unit/extensions/sphinx/CommandTest.php @@ -3,6 +3,7 @@ namespace yiiunit\extensions\sphinx; use yii\db\DataReader; +use yii\db\Expression; /** * @group sphinx @@ -128,12 +129,34 @@ class CommandTest extends SphinxTestCase $command = $db->createCommand()->update( 'yii2_test_rt_index', [ - 'title' => 'Test title', - 'content' => 'Test content', 'type_id' => $newTypeId, ], 'id = 1' ); $this->assertEquals(1, $command->execute(), 'Unable to execute update!'); + + list($row) = $db->createCommand('SELECT * FROM yii2_test_rt_index')->queryAll(); + $this->assertEquals($newTypeId, $row['type_id'], 'Unable to update attribute value!'); + } + + /** + * @depends testInsert + */ + public function testDelete() + { + $db = $this->getConnection(); + + $db->createCommand()->insert('yii2_test_rt_index', [ + 'title' => 'Test title', + 'content' => 'Test content', + 'type_id' => 2, + 'id' => 1, + ])->execute(); + + $command = $db->createCommand()->delete('yii2_test_rt_index', 'id = 1'); + $this->assertEquals(1, $command->execute(), 'Unable to execute delete!'); + + $rows = $db->createCommand('SELECT * FROM yii2_test_rt_index')->queryAll(); + $this->assertEquals(0, count($rows), 'Unable to delete record!'); } } \ No newline at end of file From 0868e7d3ce1e937012fabd895a074032dfbdc73d Mon Sep 17 00:00:00 2001 From: Paul Klimov Date: Tue, 12 Nov 2013 12:21:39 +0200 Subject: [PATCH 08/59] MVA added to "Sphinx" unit tests. --- extensions/sphinx/Schema.php | 5 +---- tests/unit/data/sphinx/sphinx.conf | 12 ++++++++++-- tests/unit/data/sphinx/sphinx.sql | 13 +++++++++++++ 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/extensions/sphinx/Schema.php b/extensions/sphinx/Schema.php index 5afb445..557becd 100644 --- a/extensions/sphinx/Schema.php +++ b/extensions/sphinx/Schema.php @@ -86,9 +86,6 @@ class Schema extends \yii\db\mysql\Schema $table->columns[$column->name] = $column; if ($column->isPrimaryKey) { $table->primaryKey[] = $column->name; - if ($column->autoIncrement) { - $table->sequenceName = ''; - } } } return true; @@ -104,9 +101,9 @@ class Schema extends \yii\db\mysql\Schema $column = new ColumnSchema; $column->name = $info['Field']; + $column->isPrimaryKey = ($column->name == 'id'); // Not supported : //$column->allowNull = $info['Null'] === 'YES'; - //$column->isPrimaryKey = strpos($info['Key'], 'PRI') !== false; //$column->autoIncrement = stripos($info['Extra'], 'auto_increment') !== false; //$column->comment = $info['Comment']; diff --git a/tests/unit/data/sphinx/sphinx.conf b/tests/unit/data/sphinx/sphinx.conf index fb28525..e58166c 100644 --- a/tests/unit/data/sphinx/sphinx.conf +++ b/tests/unit/data/sphinx/sphinx.conf @@ -1,6 +1,13 @@ +# Sphinx configuration for the unit tests # -# Minimal Sphinx configuration sample (clean, simple, functional) -# +# Setup test environment: +# - initialize test database source: +# mysql -D yii2test -u test < /path/to/yii/tests/unit/data/sphinx/sphinx.sql +# - setup test Sphinx indexes: +# indexer --config /path/to/yii/tests/unit/data/sphinx/sphinx.conf --all [--rotate] +# - run the "searchd" daemon: +# searchd --config /path/to/yii/tests/unit/data/sphinx/sphinx.conf + source yii2_test_article_src { @@ -19,6 +26,7 @@ source yii2_test_article_src sql_attr_uint = id sql_attr_uint = author_id sql_attr_timestamp = add_date + sql_attr_multi = uint tag from query; SELECT article_id AS id, tag_id AS tag FROM yii2_test_article_tag sql_query_info = SELECT * FROM yii2_test_article WHERE id=$id } diff --git a/tests/unit/data/sphinx/sphinx.sql b/tests/unit/data/sphinx/sphinx.sql index b6a2ef7..1cdd5ae 100644 --- a/tests/unit/data/sphinx/sphinx.sql +++ b/tests/unit/data/sphinx/sphinx.sql @@ -26,3 +26,16 @@ CREATE TABLE IF NOT EXISTS `yii2_test_item` ( INSERT INTO `yii2_test_item` (`id`, `name`, `description`, `category_id`, `price`) VALUES (1, 'pencil', 'Simple pencil', 1, 2.5), (2, 'table', 'Wooden table', 2, 100); + +CREATE TABLE IF NOT EXISTS `yii2_test_article_tag` ( + `article_id` int(11) NOT NULL, + `tag_id` int(11) NOT NULL, + PRIMARY KEY (`article_id`,`tag_id`) +) ENGINE=MyISAM DEFAULT CHARSET=utf8; + +INSERT INTO `yii2_test_article_tag` (`article_id`, `tag_id`) VALUES +(1, 1), +(1, 2), +(1, 3), +(2, 3), +(2, 4); \ No newline at end of file From 5a1b526ae868f6e8276c0966f588b4da3ddccf40 Mon Sep 17 00:00:00 2001 From: Paul Klimov Date: Tue, 12 Nov 2013 13:35:40 +0200 Subject: [PATCH 09/59] "yii\sphinx\ColumnSchema' extracted. --- extensions/sphinx/ColumnSchema.php | 81 +++++++++++++++++++++++ extensions/sphinx/QueryBuilder.php | 48 ++++++++++++++ extensions/sphinx/Schema.php | 20 ++---- tests/unit/data/sphinx/sphinx.conf | 1 + tests/unit/extensions/sphinx/ColumnSchemaTest.php | 55 +++++++++++++++ tests/unit/extensions/sphinx/CommandTest.php | 1 + 6 files changed, 193 insertions(+), 13 deletions(-) create mode 100644 extensions/sphinx/ColumnSchema.php create mode 100644 tests/unit/extensions/sphinx/ColumnSchemaTest.php diff --git a/extensions/sphinx/ColumnSchema.php b/extensions/sphinx/ColumnSchema.php new file mode 100644 index 0000000..47eca5f --- /dev/null +++ b/extensions/sphinx/ColumnSchema.php @@ -0,0 +1,81 @@ + + * @since 2.0 + */ +class ColumnSchema extends Object +{ + /** + * @var string name of this column (without quotes). + */ + public $name; + /** + * @var string abstract type of this column. Possible abstract types include: + * string, text, boolean, smallint, integer, bigint, float, decimal, datetime, + * timestamp, time, date, binary, and money. + */ + public $type; + /** + * @var string the PHP type of this column. Possible PHP types include: + * string, boolean, integer, double. + */ + public $phpType; + /** + * @var string the DB type of this column. Possible DB types vary according to the type of DBMS. + */ + public $dbType; + /** + * @var boolean whether this column is a primary key + */ + public $isPrimaryKey; + /** + * @var boolean whether this column is an attribute + */ + public $isAttribute; + /** + * @var boolean whether this column is a indexed field + */ + public $isField; + /** + * @var boolean whether this column is a multi value attribute (MVA) + */ + public $isMva; + + /** + * Converts the input value according to [[phpType]]. + * If the value is null or an [[Expression]], it will not be converted. + * @param mixed $value input value + * @return mixed converted value + */ + public function typecast($value) + { + if ($value === null || gettype($value) === $this->phpType || $value instanceof Expression) { + return $value; + } + if ($value === '' && $this->type !== Schema::TYPE_TEXT && $this->type !== Schema::TYPE_STRING) { + return null; + } + switch ($this->phpType) { + case 'string': + return (string)$value; + case 'integer': + return (integer)$value; + case 'boolean': + return (boolean)$value; + } + return $value; + } +} \ No newline at end of file diff --git a/extensions/sphinx/QueryBuilder.php b/extensions/sphinx/QueryBuilder.php index e3f52de..b0502e6 100644 --- a/extensions/sphinx/QueryBuilder.php +++ b/extensions/sphinx/QueryBuilder.php @@ -6,6 +6,7 @@ */ namespace yii\sphinx; +use yii\db\Expression; /** * Class QueryBuilder @@ -72,4 +73,51 @@ class QueryBuilder extends \yii\db\mysql\QueryBuilder } return 'OPTION ' . implode(', ', $optionLines); } + + /** + * Creates an INSERT SQL statement. + * For example, + * + * ~~~ + * $sql = $queryBuilder->insert('tbl_user', [ + * 'name' => 'Sam', + * 'age' => 30, + * ], $params); + * ~~~ + * + * The method will properly escape the table and column names. + * + * @param string $table the table that new rows will be inserted into. + * @param array $columns the column data (name => value) to be inserted into the table. + * @param array $params the binding parameters that will be generated by this method. + * They should be bound to the DB command later. + * @return string the INSERT SQL + */ + public function insert($table, $columns, &$params) + { + if (($tableSchema = $this->db->getTableSchema($table)) !== null) { + $columnSchemas = $tableSchema->columns; + } else { + $columnSchemas = []; + } + $names = []; + $placeholders = []; + foreach ($columns as $name => $value) { + $names[] = $this->db->quoteColumnName($name); + if ($value instanceof Expression) { + $placeholders[] = $value->expression; + foreach ($value->params as $n => $v) { + $params[$n] = $v; + } + } else { + $phName = self::PARAM_PREFIX . count($params); + $placeholders[] = $phName; + $params[$phName] = !is_array($value) && isset($columnSchemas[$name]) ? $columnSchemas[$name]->typecast($value) : $value; + } + } + + return 'INSERT INTO ' . $this->db->quoteTableName($table) + . ' (' . implode(', ', $names) . ') VALUES (' + . implode(', ', $placeholders) . ')'; + } } \ No newline at end of file diff --git a/extensions/sphinx/Schema.php b/extensions/sphinx/Schema.php index 557becd..b9a34be 100644 --- a/extensions/sphinx/Schema.php +++ b/extensions/sphinx/Schema.php @@ -7,7 +7,6 @@ namespace yii\sphinx; -use yii\db\ColumnSchema; use yii\db\TableSchema; /** @@ -101,15 +100,9 @@ class Schema extends \yii\db\mysql\Schema $column = new ColumnSchema; $column->name = $info['Field']; - $column->isPrimaryKey = ($column->name == 'id'); - // Not supported : - //$column->allowNull = $info['Null'] === 'YES'; - //$column->autoIncrement = stripos($info['Extra'], 'auto_increment') !== false; - //$column->comment = $info['Comment']; - - $column->dbType = $info['Type']; - //$column->unsigned = strpos($column->dbType, 'unsigned') !== false; + + $column->isPrimaryKey = ($column->name == 'id'); $type = $info['Type']; if (isset($this->typeMap[$type])) { @@ -118,11 +111,12 @@ class Schema extends \yii\db\mysql\Schema $column->type = self::TYPE_STRING; } - $column->phpType = $this->getColumnPhpType($column); + $column->isField = ($type == 'field'); + $column->isAttribute = !$column->isField; - /*if ($column->type !== 'timestamp' || $info['Default'] !== 'CURRENT_TIMESTAMP') { - $column->defaultValue = $column->typecast($info['Default']); - }*/ + $column->isMva = ($type == 'mva'); + + $column->phpType = $this->getColumnPhpType($column); return $column; } diff --git a/tests/unit/data/sphinx/sphinx.conf b/tests/unit/data/sphinx/sphinx.conf index e58166c..6ab6625 100644 --- a/tests/unit/data/sphinx/sphinx.conf +++ b/tests/unit/data/sphinx/sphinx.conf @@ -80,6 +80,7 @@ index yii2_test_rt_index rt_field = title rt_field = content rt_attr_uint = type_id + rt_attr_multi = category } diff --git a/tests/unit/extensions/sphinx/ColumnSchemaTest.php b/tests/unit/extensions/sphinx/ColumnSchemaTest.php new file mode 100644 index 0000000..7d62c1d --- /dev/null +++ b/tests/unit/extensions/sphinx/ColumnSchemaTest.php @@ -0,0 +1,55 @@ +type = $type; + $columnSchema->phpType = $phpType; + $this->assertEquals($expectedResult, $columnSchema->typecast($value)); + } +} \ No newline at end of file diff --git a/tests/unit/extensions/sphinx/CommandTest.php b/tests/unit/extensions/sphinx/CommandTest.php index 639dea2..0a15bf8 100644 --- a/tests/unit/extensions/sphinx/CommandTest.php +++ b/tests/unit/extensions/sphinx/CommandTest.php @@ -103,6 +103,7 @@ class CommandTest extends SphinxTestCase 'title' => 'Test title', 'content' => 'Test content', 'type_id' => 2, + 'category' => [41, 42], 'id' => 1, ]); $this->assertEquals(1, $command->execute(), 'Unable to execute insert!'); From 7cc182fc727d6d9fe691d91657afef232924da7d Mon Sep 17 00:00:00 2001 From: Paul Klimov Date: Tue, 12 Nov 2013 14:31:55 +0200 Subject: [PATCH 10/59] "yii\sphinx\IndexSchema' created. "yii\sphinx\Schema' reworked to drop inheritance from "yii\db". --- extensions/sphinx/IndexSchema.php | 82 ++++++++ extensions/sphinx/Schema.php | 382 +++++++++++++++++++++++++++++++++++--- 2 files changed, 443 insertions(+), 21 deletions(-) create mode 100644 extensions/sphinx/IndexSchema.php diff --git a/extensions/sphinx/IndexSchema.php b/extensions/sphinx/IndexSchema.php new file mode 100644 index 0000000..81ba075 --- /dev/null +++ b/extensions/sphinx/IndexSchema.php @@ -0,0 +1,82 @@ + + * @since 2.0 + */ +class IndexSchema extends Object +{ + /** + * @var string name of the schema that this index belongs to. + */ + public $schemaName; + /** + * @var string name of this index. + */ + public $name; + /** + * @var string[] primary keys of this index. + */ + public $primaryKey = []; + /** + * @var ColumnSchema[] column metadata of this index. Each array element is a [[ColumnSchema]] object, indexed by column names. + */ + public $columns = []; + + /** + * Gets the named column metadata. + * This is a convenient method for retrieving a named column even if it does not exist. + * @param string $name column name + * @return ColumnSchema metadata of the named column. Null if the named column does not exist. + */ + public function getColumn($name) + { + return isset($this->columns[$name]) ? $this->columns[$name] : null; + } + + /** + * Returns the names of all columns in this table. + * @return array list of column names + */ + public function getColumnNames() + { + return array_keys($this->columns); + } + + /** + * Manually specifies the primary key for this table. + * @param string|array $keys the primary key (can be composite) + * @throws InvalidParamException if the specified key cannot be found in the table. + */ + public function fixPrimaryKey($keys) + { + if (!is_array($keys)) { + $keys = [$keys]; + } + $this->primaryKey = $keys; + foreach ($this->columns as $column) { + $column->isPrimaryKey = false; + } + foreach ($keys as $key) { + if (isset($this->columns[$key])) { + $this->columns[$key]->isPrimaryKey = true; + } else { + throw new InvalidParamException("Primary key '$key' cannot be found in index '{$this->name}'."); + } + } + } +} \ No newline at end of file diff --git a/extensions/sphinx/Schema.php b/extensions/sphinx/Schema.php index b9a34be..8313ae4 100644 --- a/extensions/sphinx/Schema.php +++ b/extensions/sphinx/Schema.php @@ -7,17 +7,48 @@ namespace yii\sphinx; -use yii\db\TableSchema; +use yii\base\Object; +use yii\caching\Cache; +use Yii; +use yii\caching\GroupDependency; /** - * Class Schema + * Schema represents the Sphinx schema information. * * @author Paul Klimov * @since 2.0 */ -class Schema extends \yii\db\mysql\Schema +class Schema extends Object { /** + * The followings are the supported abstract column data types. + */ + const TYPE_PK = 'pk'; + const TYPE_STRING = 'string'; + const TYPE_INTEGER = 'integer'; + const TYPE_BIGINT = 'bigint'; + const TYPE_FLOAT = 'float'; + const TYPE_TIMESTAMP = 'timestamp'; + const TYPE_BOOLEAN = 'boolean'; + + /** + * @var Connection the Sphinx connection + */ + public $db; + /** + * @var array list of ALL index names in the Sphinx + */ + private $_indexNames = []; + /** + * @var array list of loaded index metadata (index name => IndexSchema) + */ + private $_indexes = []; + /** + * @var QueryBuilder the query builder for this Sphinx connection + */ + private $_builder; + + /** * @var array mapping from physical column types (keys) to abstract column types (values) */ public $typeMap = [ @@ -35,8 +66,200 @@ class Schema extends \yii\db\mysql\Schema ]; /** - * Creates a query builder for the database. - * This method may be overridden by child classes to create a DBMS-specific query builder. + * Loads the metadata for the specified index. + * @param string $name index name + * @return IndexSchema driver dependent index metadata. Null if the index does not exist. + */ + protected function loadTableSchema($name) + { + $index = new IndexSchema; + $this->resolveIndexNames($index, $name); + + if ($this->findColumns($index)) { + return $index; + } else { + return null; + } + } + + /** + * Resolves the index name and schema name (if any). + * @param IndexSchema $index the index metadata object + * @param string $name the index name + */ + protected function resolveIndexNames($index, $name) + { + $parts = explode('.', str_replace('`', '', $name)); + if (isset($parts[1])) { + $index->schemaName = $parts[0]; + $index->name = $parts[1]; + } else { + $index->name = $parts[0]; + } + } + + /** + * Obtains the metadata for the named index. + * @param string $name index name. The index name may contain schema name if any. Do not quote the index name. + * @param boolean $refresh whether to reload the index schema even if it is found in the cache. + * @return IndexSchema index metadata. Null if the named index does not exist. + */ + public function getIndexSchema($name, $refresh = false) + { + if (isset($this->_indexes[$name]) && !$refresh) { + return $this->_indexes[$name]; + } + + $db = $this->db; + $realName = $this->getRawIndexName($name); + + if ($db->enableSchemaCache && !in_array($name, $db->schemaCacheExclude, true)) { + /** @var $cache Cache */ + $cache = is_string($db->schemaCache) ? Yii::$app->getComponent($db->schemaCache) : $db->schemaCache; + if ($cache instanceof Cache) { + $key = $this->getCacheKey($name); + if ($refresh || ($index = $cache->get($key)) === false) { + $index = $this->loadTableSchema($realName); + if ($index !== null) { + $cache->set($key, $index, $db->schemaCacheDuration, new GroupDependency($this->getCacheGroup())); + } + } + return $this->_indexes[$name] = $index; + } + } + return $this->_indexes[$name] = $index = $this->loadTableSchema($realName); + } + + /** + * Returns the cache key for the specified index name. + * @param string $name the index name + * @return mixed the cache key + */ + protected function getCacheKey($name) + { + return [ + __CLASS__, + $this->db->dsn, + $this->db->username, + $name, + ]; + } + + /** + * Returns the cache group name. + * This allows [[refresh()]] to invalidate all cached index schemas. + * @return string the cache group name + */ + protected function getCacheGroup() + { + return md5(serialize([ + __CLASS__, + $this->db->dsn, + $this->db->username, + ])); + } + + /** + * Returns the metadata for all indexes in the database. + * @param string $schema the schema of the indexes. Defaults to empty string, meaning the current or default schema name. + * @param boolean $refresh whether to fetch the latest available index schemas. If this is false, + * cached data may be returned if available. + * @return IndexSchema[] the metadata for all indexes in the Sphinx. + * Each array element is an instance of [[IndexSchema]] or its child class. + */ + public function getTableSchemas($schema = '', $refresh = false) + { + $indexes = []; + foreach ($this->getIndexNames($schema, $refresh) as $name) { + if ($schema !== '') { + $name = $schema . '.' . $name; + } + if (($index = $this->getIndexSchema($name, $refresh)) !== null) { + $indexes[] = $index; + } + } + return $indexes; + } + + /** + * Returns all index names in the database. + * @param string $schema the schema of the indexes. Defaults to empty string, meaning the current or default schema name. + * If not empty, the returned index names will be prefixed with the schema name. + * @param boolean $refresh whether to fetch the latest available index names. If this is false, + * index names fetched previously (if available) will be returned. + * @return string[] all index names in the database. + */ + public function getIndexNames($schema = '', $refresh = false) + { + if (!isset($this->_indexNames[$schema]) || $refresh) { + $this->_indexNames[$schema] = $this->findIndexNames($schema); + } + return $this->_indexNames[$schema]; + } + + /** + * Returns all index names in the database. + * @param string $schema the schema of the indexes. Defaults to empty string, meaning the current or default schema. + * @return array all index names in the database. The names have NO schema name prefix. + */ + protected function findIndexNames($schema = '') + { + $sql = 'SHOW TABLES'; + if ($schema !== '') { + $sql .= ' FROM ' . $this->quoteSimpleIndexName($schema); + } + return $this->db->createCommand($sql)->queryColumn(); + } + + /** + * @return QueryBuilder the query builder for this connection. + */ + public function getQueryBuilder() + { + if ($this->_builder === null) { + $this->_builder = $this->createQueryBuilder(); + } + return $this->_builder; + } + + /** + * Determines the PDO type for the given PHP data value. + * @param mixed $data the data whose PDO type is to be determined + * @return integer the PDO type + * @see http://www.php.net/manual/en/pdo.constants.php + */ + public function getPdoType($data) + { + static $typeMap = [ + // php type => PDO type + 'boolean' => \PDO::PARAM_BOOL, + 'integer' => \PDO::PARAM_INT, + 'string' => \PDO::PARAM_STR, + 'resource' => \PDO::PARAM_LOB, + 'NULL' => \PDO::PARAM_NULL, + ]; + $type = gettype($data); + return isset($typeMap[$type]) ? $typeMap[$type] : \PDO::PARAM_STR; + } + + /** + * Refreshes the schema. + * This method cleans up all cached index schemas so that they can be re-created later + * to reflect the Sphinx schema change. + */ + public function refresh() + { + /** @var $cache Cache */ + $cache = is_string($this->db->schemaCache) ? Yii::$app->getComponent($this->db->schemaCache) : $this->db->schemaCache; + if ($this->db->enableSchemaCache && $cache instanceof Cache) { + GroupDependency::invalidate($cache, $this->getCacheGroup()); + } + $this->_indexNames = []; + $this->_indexes = []; + } + + /** + * Creates a query builder for the Sphinx. * @return QueryBuilder query builder instance */ public function createQueryBuilder() @@ -45,31 +268,148 @@ class Schema extends \yii\db\mysql\Schema } /** - * Loads the metadata for the specified table. - * @param string $name table name - * @return TableSchema driver dependent table metadata. Null if the table does not exist. + * Quotes a string value for use in a query. + * Note that if the parameter is not a string, it will be returned without change. + * @param string $str string to be quoted + * @return string the properly quoted string + * @see http://www.php.net/manual/en/function.PDO-quote.php */ - protected function loadTableSchema($name) + public function quoteValue($str) + { + if (!is_string($str)) { + return $str; + } + + $this->db->open(); + if (($value = $this->db->pdo->quote($str)) !== false) { + return $value; + } else { // the driver doesn't support quote (e.g. oci) + return "'" . addcslashes(str_replace("'", "''", $str), "\000\n\r\\\032") . "'"; + } + } + + /** + * Quotes a index name for use in a query. + * If the index name contains schema prefix, the prefix will also be properly quoted. + * If the index name is already quoted or contains '(' or '{{', + * then this method will do nothing. + * @param string $name index name + * @return string the properly quoted index name + * @see quoteSimpleTableName + */ + public function quoteIndexName($name) + { + if (strpos($name, '(') !== false || strpos($name, '{{') !== false) { + return $name; + } + if (strpos($name, '.') === false) { + return $this->quoteSimpleIndexName($name); + } + $parts = explode('.', $name); + foreach ($parts as $i => $part) { + $parts[$i] = $this->quoteSimpleIndexName($part); + } + return implode('.', $parts); + } + + /** + * Quotes a column name for use in a query. + * If the column name contains prefix, the prefix will also be properly quoted. + * If the column name is already quoted or contains '(', '[[' or '{{', + * then this method will do nothing. + * @param string $name column name + * @return string the properly quoted column name + * @see quoteSimpleColumnName + */ + public function quoteColumnName($name) + { + if (strpos($name, '(') !== false || strpos($name, '[[') !== false || strpos($name, '{{') !== false) { + return $name; + } + if (($pos = strrpos($name, '.')) !== false) { + $prefix = $this->quoteIndexName(substr($name, 0, $pos)) . '.'; + $name = substr($name, $pos + 1); + } else { + $prefix = ''; + } + return $prefix . $this->quoteSimpleColumnName($name); + } + + /** + * Quotes a index name for use in a query. + * A simple index name has no schema prefix. + * @param string $name index name + * @return string the properly quoted index name + */ + public function quoteSimpleIndexName($name) + { + return strpos($name, "`") !== false ? $name : "`" . $name . "`"; + } + + /** + * Quotes a column name for use in a query. + * A simple column name has no prefix. + * @param string $name column name + * @return string the properly quoted column name + */ + public function quoteSimpleColumnName($name) { - $table = new TableSchema; - $this->resolveTableNames($table, $name); + return strpos($name, '`') !== false || $name === '*' ? $name : '`' . $name . '`'; + } - if ($this->findColumns($table)) { - return $table; + /** + * Returns the actual name of a given index name. + * This method will strip off curly brackets from the given index name + * and replace the percentage character '%' with [[Connection::indexPrefix]]. + * @param string $name the index name to be converted + * @return string the real name of the given index name + */ + public function getRawIndexName($name) + { + if (strpos($name, '{{') !== false) { + $name = preg_replace('/\\{\\{(.*?)\\}\\}/', '\1', $name); + return str_replace('%', $this->db->tablePrefix, $name); } else { - return null; + return $name; + } + } + + /** + * Extracts the PHP type from abstract DB type. + * @param ColumnSchema $column the column schema information + * @return string PHP type name + */ + protected function getColumnPhpType($column) + { + static $typeMap = [ // abstract type => php type + 'smallint' => 'integer', + 'integer' => 'integer', + 'bigint' => 'integer', + 'boolean' => 'boolean', + 'float' => 'double', + ]; + if (isset($typeMap[$column->type])) { + if ($column->type === 'bigint') { + return PHP_INT_SIZE == 8 ? 'integer' : 'string'; + } elseif ($column->type === 'integer') { + return PHP_INT_SIZE == 4 ? 'string' : 'integer'; + } else { + return $typeMap[$column->type]; + } + } else { + return 'string'; } } /** - * Collects the metadata of table columns. - * @param TableSchema $table the table metadata - * @return boolean whether the table exists in the database + * Collects the metadata of index columns. + * @param IndexSchema $index the index metadata + * @return boolean whether the index exists in the database * @throws \Exception if DB query fails */ - protected function findColumns($table) + protected function findColumns($index) { - $sql = 'DESCRIBE ' . $this->quoteSimpleTableName($table->name); + $sql = 'DESCRIBE ' . $this->quoteSimpleIndexName($index->name); try { $columns = $this->db->createCommand($sql)->queryAll(); } catch (\Exception $e) { @@ -82,9 +422,9 @@ class Schema extends \yii\db\mysql\Schema } foreach ($columns as $info) { $column = $this->loadColumnSchema($info); - $table->columns[$column->name] = $column; + $index->columns[$column->name] = $column; if ($column->isPrimaryKey) { - $table->primaryKey[] = $column->name; + $index->primaryKey[] = $column->name; } } return true; From cbfa7e6129116e400f419357a3a5af1f63cac481 Mon Sep 17 00:00:00 2001 From: Paul Klimov Date: Tue, 12 Nov 2013 16:15:37 +0200 Subject: [PATCH 11/59] "yii\sphinx\Command" and "yii\sphinx\QueryBuilder" extracted. --- extensions/sphinx/Command.php | 483 +++++++++++++++++++- extensions/sphinx/Connection.php | 26 ++ extensions/sphinx/QueryBuilder.php | 648 +++++++++++++++++++++++++-- tests/unit/extensions/sphinx/CommandTest.php | 2 +- 4 files changed, 1113 insertions(+), 46 deletions(-) diff --git a/extensions/sphinx/Command.php b/extensions/sphinx/Command.php index b457b9a..d22d055 100644 --- a/extensions/sphinx/Command.php +++ b/extensions/sphinx/Command.php @@ -7,13 +7,492 @@ namespace yii\sphinx; +use Yii; +use yii\base\Component; +use yii\caching\Cache; +use yii\db\DataReader; +use yii\db\Exception; + /** * Class Command * * @author Paul Klimov * @since 2.0 */ -class Command extends \yii\db\Command +class Command extends Component { - // + /** + * @var Connection the Sphinx connection that this command is associated with + */ + public $db; + /** + * @var \PDOStatement the PDOStatement object that this command is associated with + */ + public $pdoStatement; + /** + * @var integer the default fetch mode for this command. + * @see http://www.php.net/manual/en/function.PDOStatement-setFetchMode.php + */ + public $fetchMode = \PDO::FETCH_ASSOC; + /** + * @var array the parameters (name => value) that are bound to the current PDO statement. + * This property is maintained by methods such as [[bindValue()]]. + * Do not modify it directly. + */ + public $params = []; + /** + * @var string the SphinxQL statement that this command represents + */ + private $_sql; + + /** + * Returns the SQL statement for this command. + * @return string the SQL statement to be executed + */ + public function getSql() + { + return $this->_sql; + } + + /** + * Specifies the SQL statement to be executed. + * The previous SQL execution (if any) will be cancelled, and [[params]] will be cleared as well. + * @param string $sql the SQL statement to be set. + * @return static this command instance + */ + public function setSql($sql) + { + if ($sql !== $this->_sql) { + $this->cancel(); + $this->_sql = $this->db->quoteSql($sql); + $this->params = []; + } + return $this; + } + + /** + * Returns the raw SQL by inserting parameter values into the corresponding placeholders in [[sql]]. + * Note that the return value of this method should mainly be used for logging purpose. + * It is likely that this method returns an invalid SQL due to improper replacement of parameter placeholders. + * @return string the raw SQL with parameter values inserted into the corresponding placeholders in [[sql]]. + */ + public function getRawSql() + { + if (empty($this->params)) { + return $this->_sql; + } else { + $params = []; + foreach ($this->params as $name => $value) { + if (is_string($value)) { + $params[$name] = $this->db->quoteValue($value); + } elseif ($value === null) { + $params[$name] = 'NULL'; + } else { + $params[$name] = $value; + } + } + if (isset($params[1])) { + $sql = ''; + foreach (explode('?', $this->_sql) as $i => $part) { + $sql .= (isset($params[$i]) ? $params[$i] : '') . $part; + } + return $sql; + } else { + return strtr($this->_sql, $params); + } + } + } + + /** + * Prepares the SQL statement to be executed. + * For complex SQL statement that is to be executed multiple times, + * this may improve performance. + * For SQL statement with binding parameters, this method is invoked + * automatically. + * @throws Exception if there is any DB error + */ + public function prepare() + { + if ($this->pdoStatement == null) { + $sql = $this->getSql(); + try { + $this->pdoStatement = $this->db->pdo->prepare($sql); + } catch (\Exception $e) { + $message = $e->getMessage() . "\nFailed to prepare SQL: $sql"; + $errorInfo = $e instanceof \PDOException ? $e->errorInfo : null; + throw new Exception($message, $errorInfo, (int)$e->getCode(), $e); + } + } + } + + /** + * Cancels the execution of the SQL statement. + * This method mainly sets [[pdoStatement]] to be null. + */ + public function cancel() + { + $this->pdoStatement = null; + } + + /** + * Binds a parameter to the SQL statement to be executed. + * @param string|integer $name parameter identifier. For a prepared statement + * using named placeholders, this will be a parameter name of + * the form `:name`. For a prepared statement using question mark + * placeholders, this will be the 1-indexed position of the parameter. + * @param mixed $value Name of the PHP variable to bind to the SQL statement parameter + * @param integer $dataType SQL data type of the parameter. If null, the type is determined by the PHP type of the value. + * @param integer $length length of the data type + * @param mixed $driverOptions the driver-specific options + * @return static the current command being executed + * @see http://www.php.net/manual/en/function.PDOStatement-bindParam.php + */ + public function bindParam($name, &$value, $dataType = null, $length = null, $driverOptions = null) + { + $this->prepare(); + if ($dataType === null) { + $dataType = $this->db->getSchema()->getPdoType($value); + } + if ($length === null) { + $this->pdoStatement->bindParam($name, $value, $dataType); + } elseif ($driverOptions === null) { + $this->pdoStatement->bindParam($name, $value, $dataType, $length); + } else { + $this->pdoStatement->bindParam($name, $value, $dataType, $length, $driverOptions); + } + $this->params[$name] =& $value; + return $this; + } + + /** + * Binds a value to a parameter. + * @param string|integer $name Parameter identifier. For a prepared statement + * using named placeholders, this will be a parameter name of + * the form `:name`. For a prepared statement using question mark + * placeholders, this will be the 1-indexed position of the parameter. + * @param mixed $value The value to bind to the parameter + * @param integer $dataType SQL data type of the parameter. If null, the type is determined by the PHP type of the value. + * @return static the current command being executed + * @see http://www.php.net/manual/en/function.PDOStatement-bindValue.php + */ + public function bindValue($name, $value, $dataType = null) + { + $this->prepare(); + if ($dataType === null) { + $dataType = $this->db->getSchema()->getPdoType($value); + } + $this->pdoStatement->bindValue($name, $value, $dataType); + $this->params[$name] = $value; + return $this; + } + + /** + * Binds a list of values to the corresponding parameters. + * This is similar to [[bindValue()]] except that it binds multiple values at a time. + * Note that the SQL data type of each value is determined by its PHP type. + * @param array $values the values to be bound. This must be given in terms of an associative + * array with array keys being the parameter names, and array values the corresponding parameter values, + * e.g. `[':name' => 'John', ':age' => 25]`. By default, the PDO type of each value is determined + * by its PHP type. You may explicitly specify the PDO type by using an array: `[value, type]`, + * e.g. `[':name' => 'John', ':profile' => [$profile, \PDO::PARAM_LOB]]`. + * @return static the current command being executed + */ + public function bindValues($values) + { + if (!empty($values)) { + $this->prepare(); + foreach ($values as $name => $value) { + if (is_array($value)) { + $type = $value[1]; + $value = $value[0]; + } else { + $type = $this->db->getSchema()->getPdoType($value); + } + $this->pdoStatement->bindValue($name, $value, $type); + $this->params[$name] = $value; + } + } + return $this; + } + + /** + * Executes the SQL statement. + * This method should only be used for executing non-query SQL statement, such as `INSERT`, `DELETE`, `UPDATE` SQLs. + * No result set will be returned. + * @return integer number of rows affected by the execution. + * @throws Exception execution failed + */ + public function execute() + { + $sql = $this->getSql(); + + $rawSql = $this->getRawSql(); + + Yii::trace($rawSql, __METHOD__); + + if ($sql == '') { + return 0; + } + + $token = $rawSql; + try { + Yii::beginProfile($token, __METHOD__); + + $this->prepare(); + $this->pdoStatement->execute(); + $n = $this->pdoStatement->rowCount(); + + Yii::endProfile($token, __METHOD__); + return $n; + } catch (\Exception $e) { + Yii::endProfile($token, __METHOD__); + $message = $e->getMessage() . "\nThe SQL being executed was: $rawSql"; + $errorInfo = $e instanceof \PDOException ? $e->errorInfo : null; + throw new Exception($message, $errorInfo, (int)$e->getCode(), $e); + } + } + + /** + * Executes the SQL statement and returns query result. + * This method is for executing a SQL query that returns result set, such as `SELECT`. + * @return DataReader the reader object for fetching the query result + * @throws Exception execution failed + */ + public function query() + { + return $this->queryInternal(''); + } + + /** + * Executes the SQL statement and returns ALL rows at once. + * @param integer $fetchMode the result fetch mode. Please refer to [PHP manual](http://www.php.net/manual/en/function.PDOStatement-setFetchMode.php) + * for valid fetch modes. If this parameter is null, the value set in [[fetchMode]] will be used. + * @return array all rows of the query result. Each array element is an array representing a row of data. + * An empty array is returned if the query results in nothing. + * @throws Exception execution failed + */ + public function queryAll($fetchMode = null) + { + return $this->queryInternal('fetchAll', $fetchMode); + } + + /** + * Executes the SQL statement and returns the first row of the result. + * This method is best used when only the first row of result is needed for a query. + * @param integer $fetchMode the result fetch mode. Please refer to [PHP manual](http://www.php.net/manual/en/function.PDOStatement-setFetchMode.php) + * for valid fetch modes. If this parameter is null, the value set in [[fetchMode]] will be used. + * @return array|boolean the first row (in terms of an array) of the query result. False is returned if the query + * results in nothing. + * @throws Exception execution failed + */ + public function queryOne($fetchMode = null) + { + return $this->queryInternal('fetch', $fetchMode); + } + + /** + * Executes the SQL statement and returns the value of the first column in the first row of data. + * This method is best used when only a single value is needed for a query. + * @return string|boolean the value of the first column in the first row of the query result. + * False is returned if there is no value. + * @throws Exception execution failed + */ + public function queryScalar() + { + $result = $this->queryInternal('fetchColumn', 0); + if (is_resource($result) && get_resource_type($result) === 'stream') { + return stream_get_contents($result); + } else { + return $result; + } + } + + /** + * Executes the SQL statement and returns the first column of the result. + * This method is best used when only the first column of result (i.e. the first element in each row) + * is needed for a query. + * @return array the first column of the query result. Empty array is returned if the query results in nothing. + * @throws Exception execution failed + */ + public function queryColumn() + { + return $this->queryInternal('fetchAll', \PDO::FETCH_COLUMN); + } + + /** + * Performs the actual DB query of a SQL statement. + * @param string $method method of PDOStatement to be called + * @param integer $fetchMode the result fetch mode. Please refer to [PHP manual](http://www.php.net/manual/en/function.PDOStatement-setFetchMode.php) + * for valid fetch modes. If this parameter is null, the value set in [[fetchMode]] will be used. + * @return mixed the method execution result + * @throws Exception if the query causes any problem + */ + private function queryInternal($method, $fetchMode = null) + { + $db = $this->db; + $rawSql = $this->getRawSql(); + + Yii::trace($rawSql, __METHOD__); + + /** @var $cache \yii\caching\Cache */ + if ($db->enableQueryCache && $method !== '') { + $cache = is_string($db->queryCache) ? Yii::$app->getComponent($db->queryCache) : $db->queryCache; + } + + if (isset($cache) && $cache instanceof Cache) { + $cacheKey = [ + __CLASS__, + $db->dsn, + $db->username, + $rawSql, + ]; + if (($result = $cache->get($cacheKey)) !== false) { + Yii::trace('Query result served from cache', __METHOD__); + return $result; + } + } + + $token = $rawSql; + try { + Yii::beginProfile($token, __METHOD__); + + $this->prepare(); + $this->pdoStatement->execute(); + + if ($method === '') { + $result = new DataReader($this); + } else { + if ($fetchMode === null) { + $fetchMode = $this->fetchMode; + } + $result = call_user_func_array([$this->pdoStatement, $method], (array)$fetchMode); + $this->pdoStatement->closeCursor(); + } + + Yii::endProfile($token, __METHOD__); + + if (isset($cache, $cacheKey) && $cache instanceof Cache) { + $cache->set($cacheKey, $result, $db->queryCacheDuration, $db->queryCacheDependency); + Yii::trace('Saved query result in cache', __METHOD__); + } + + return $result; + } catch (\Exception $e) { + Yii::endProfile($token, __METHOD__); + $message = $e->getMessage() . "\nThe SQL being executed was: $rawSql"; + $errorInfo = $e instanceof \PDOException ? $e->errorInfo : null; + throw new Exception($message, $errorInfo, (int)$e->getCode(), $e); + } + } + + /** + * Creates an INSERT command. + * For example, + * + * ~~~ + * $connection->createCommand()->insert('idx_user', [ + * 'name' => 'Sam', + * 'age' => 30, + * ])->execute(); + * ~~~ + * + * The method will properly escape the column names, and bind the values to be inserted. + * + * Note that the created command is not executed until [[execute()]] is called. + * + * @param string $index the index that new rows will be inserted into. + * @param array $columns the column data (name => value) to be inserted into the index. + * @return static the command object itself + */ + public function insert($index, $columns) + { + $params = []; + $sql = $this->db->getQueryBuilder()->insert($index, $columns, $params); + return $this->setSql($sql)->bindValues($params); + } + + /** + * Creates a batch INSERT command. + * For example, + * + * ~~~ + * $connection->createCommand()->batchInsert('idx_user', ['name', 'age'], [ + * ['Tom', 30], + * ['Jane', 20], + * ['Linda', 25], + * ])->execute(); + * ~~~ + * + * Note that the values in each row must match the corresponding column names. + * + * @param string $index the index that new rows will be inserted into. + * @param array $columns the column names + * @param array $rows the rows to be batch inserted into the index + * @return static the command object itself + */ + public function batchInsert($index, $columns, $rows) + { + $sql = $this->db->getQueryBuilder()->batchInsert($index, $columns, $rows); + return $this->setSql($sql); + } + + /** + * Creates an UPDATE command. + * For example, + * + * ~~~ + * $connection->createCommand()->update('tbl_user', ['status' => 1], 'age > 30')->execute(); + * ~~~ + * + * The method will properly escape the column names and bind the values to be updated. + * + * Note that the created command is not executed until [[execute()]] is called. + * + * @param string $index the index to be updated. + * @param array $columns the column data (name => value) to be updated. + * @param string|array $condition the condition that will be put in the WHERE part. Please + * refer to [[Query::where()]] on how to specify condition. + * @param array $params the parameters to be bound to the command + * @return static the command object itself + */ + public function update($index, $columns, $condition = '', $params = []) + { + $sql = $this->db->getQueryBuilder()->update($index, $columns, $condition, $params); + return $this->setSql($sql)->bindValues($params); + } + + /** + * Creates a DELETE command. + * For example, + * + * ~~~ + * $connection->createCommand()->delete('tbl_user', 'status = 0')->execute(); + * ~~~ + * + * The method will properly escape the index and column names. + * + * Note that the created command is not executed until [[execute()]] is called. + * + * @param string $index the index where the data will be deleted from. + * @param string|array $condition the condition that will be put in the WHERE part. Please + * refer to [[Query::where()]] on how to specify condition. + * @param array $params the parameters to be bound to the command + * @return static the command object itself + */ + public function delete($index, $condition = '', $params = []) + { + $sql = $this->db->getQueryBuilder()->delete($index, $condition, $params); + return $this->setSql($sql)->bindValues($params); + } + + /** + * Creates a SQL command for truncating a runtime index. + * @param string $index the index to be truncated. The name will be properly quoted by the method. + * @return static the command object itself + */ + public function truncateIndex($index) + { + $sql = $this->db->getQueryBuilder()->truncateIndex($index); + return $this->setSql($sql); + } } \ No newline at end of file diff --git a/extensions/sphinx/Connection.php b/extensions/sphinx/Connection.php index a36afb9..59ce4b6 100644 --- a/extensions/sphinx/Connection.php +++ b/extensions/sphinx/Connection.php @@ -10,6 +10,8 @@ namespace yii\sphinx; /** * Class Connection * + * @method Schema getSchema() The schema information for this Sphinx connection + * * @author Paul Klimov * @since 2.0 */ @@ -22,4 +24,28 @@ class Connection extends \yii\db\Connection 'mysqli' => 'yii\sphinx\Schema', // MySQL 'mysql' => 'yii\sphinx\Schema', // MySQL ]; + + /** + * Obtains the schema information for the named index. + * @param string $name index name. + * @param boolean $refresh whether to reload the table schema even if it is found in the cache. + * @return IndexSchema index schema information. Null if the named index does not exist. + */ + public function getIndexSchema($name, $refresh = false) + { + return $this->getSchema()->getIndexSchema($name, $refresh); + } + + /** + * Quotes a index name for use in a query. + * If the index name contains schema prefix, the prefix will also be properly quoted. + * If the index name is already quoted or contains special characters including '(', '[[' and '{{', + * then this method will do nothing. + * @param string $name index name + * @return string the properly quoted index name + */ + public function quoteIndexName($name) + { + return $this->getSchema()->quoteIndexName($name); + } } \ No newline at end of file diff --git a/extensions/sphinx/QueryBuilder.php b/extensions/sphinx/QueryBuilder.php index b0502e6..02f0145 100644 --- a/extensions/sphinx/QueryBuilder.php +++ b/extensions/sphinx/QueryBuilder.php @@ -6,6 +6,9 @@ */ namespace yii\sphinx; + +use yii\base\Object; +use yii\db\Exception; use yii\db\Expression; /** @@ -14,9 +17,35 @@ use yii\db\Expression; * @author Paul Klimov * @since 2.0 */ -class QueryBuilder extends \yii\db\mysql\QueryBuilder +class QueryBuilder extends Object { /** + * The prefix for automatically generated query binding parameters. + */ + const PARAM_PREFIX = ':sp'; + + /** + * @var Connection the Sphinx connection. + */ + public $db; + /** + * @var string the separator between different fragments of a SQL statement. + * Defaults to an empty space. This is mainly used by [[build()]] when generating a SQL statement. + */ + public $separator = " "; + + /** + * Constructor. + * @param Connection $connection the Sphinx 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); + } + + /** * Generates a SELECT SQL statement from a [[Query]] object. * @param Query $query the [[Query]] object from which the SQL statement will be generated * @return array the generated SQL statement (the first array element) and the corresponding @@ -39,85 +68,618 @@ class QueryBuilder extends \yii\db\mysql\QueryBuilder } /** - * @param array $columns - * @return string the ORDER BY clause built from [[query]]. + * Creates an INSERT SQL statement. + * For example, + * + * ~~~ + * $sql = $queryBuilder->insert('idx_user', [ + * 'name' => 'Sam', + * 'age' => 30, + * ], $params); + * ~~~ + * + * The method will properly escape the index and column names. + * + * @param string $index the index that new rows will be inserted into. + * @param array $columns the column data (name => value) to be inserted into the index. + * @param array $params the binding parameters that will be generated by this method. + * They should be bound to the DB command later. + * @return string the INSERT SQL */ - public function buildWithin($columns) + public function insert($index, $columns, &$params) { - if (empty($columns)) { - return ''; + if (($indexSchema = $this->db->getIndexSchema($index)) !== null) { + $columnSchemas = $indexSchema->columns; + } else { + $columnSchemas = []; } - $orders = []; - foreach ($columns as $name => $direction) { - if (is_object($direction)) { - $orders[] = (string)$direction; + $names = []; + $placeholders = []; + foreach ($columns as $name => $value) { + $names[] = $this->db->quoteColumnName($name); + if ($value instanceof Expression) { + $placeholders[] = $value->expression; + foreach ($value->params as $n => $v) { + $params[$n] = $v; + } } else { - $orders[] = $this->db->quoteColumnName($name) . ($direction === Query::SORT_DESC ? ' DESC' : ''); + $phName = self::PARAM_PREFIX . count($params); + $placeholders[] = $phName; + $params[$phName] = !is_array($value) && isset($columnSchemas[$name]) ? $columnSchemas[$name]->typecast($value) : $value; } } - return 'WITHIN GROUP ORDER BY ' . implode(', ', $orders); + + return 'INSERT INTO ' . $this->db->quoteIndexName($index) + . ' (' . implode(', ', $names) . ') VALUES (' + . implode(', ', $placeholders) . ')'; } /** - * @param array $options - * @return string the OPTION clause build from [[query]] + * Generates a batch INSERT SQL statement. + * For example, + * + * ~~~ + * $connection->createCommand()->batchInsert('idx_user', ['name', 'age'], [ + * ['Tom', 30], + * ['Jane', 20], + * ['Linda', 25], + * ])->execute(); + * ~~~ + * + * Note that the values in each row must match the corresponding column names. + * + * @param string $index the index that new rows will be inserted into. + * @param array $columns the column names + * @param array $rows the rows to be batch inserted into the index + * @return string the batch INSERT SQL statement */ - public function buildOption(array $options) + public function batchInsert($index, $columns, $rows) { - if (empty($options)) { - return ''; + if (($indexSchema = $this->db->getIndexSchema($index)) !== null) { + $columnSchemas = $indexSchema->columns; + } else { + $columnSchemas = []; } - $optionLines = []; - foreach ($options as $name => $value) { - $optionLines[] = $name . ' = ' . $value; + + foreach ($columns as $i => $name) { + $columns[$i] = $this->db->quoteColumnName($name); } - return 'OPTION ' . implode(', ', $optionLines); + + $values = []; + foreach ($rows as $row) { + $vs = []; + foreach ($row as $i => $value) { + if (!is_array($value) && isset($columnSchemas[$columns[$i]])) { + $value = $columnSchemas[$columns[$i]]->typecast($value); + } + $vs[] = is_string($value) ? $this->db->quoteValue($value) : $value; + } + $values[] = '(' . implode(', ', $vs) . ')'; + } + + return 'INSERT INTO ' . $this->db->quoteIndexName($index) + . ' (' . implode(', ', $columns) . ') VALUES ' . implode(', ', $values); } /** - * Creates an INSERT SQL statement. + * Creates an UPDATE SQL statement. * For example, * * ~~~ - * $sql = $queryBuilder->insert('tbl_user', [ - * 'name' => 'Sam', - * 'age' => 30, - * ], $params); + * $params = []; + * $sql = $queryBuilder->update('idx_user', ['status' => 1], 'age > 30', $params); * ~~~ * - * The method will properly escape the table and column names. + * The method will properly escape the index and column names. * - * @param string $table the table that new rows will be inserted into. - * @param array $columns the column data (name => value) to be inserted into the table. - * @param array $params the binding parameters that will be generated by this method. - * They should be bound to the DB command later. - * @return string the INSERT SQL + * @param string $index the index to be updated. + * @param array $columns the column data (name => value) to be updated. + * @param array|string $condition the condition that will be put in the WHERE part. Please + * refer to [[Query::where()]] on how to specify condition. + * @param array $params the binding parameters that will be modified by this method + * so that they can be bound to the DB command later. + * @return string the UPDATE SQL */ - public function insert($table, $columns, &$params) + public function update($index, $columns, $condition, &$params) { - if (($tableSchema = $this->db->getTableSchema($table)) !== null) { - $columnSchemas = $tableSchema->columns; + if (($indexSchema = $this->db->getIndexSchema($index)) !== null) { + $columnSchemas = $indexSchema->columns; } else { $columnSchemas = []; } - $names = []; - $placeholders = []; + + $lines = []; foreach ($columns as $name => $value) { - $names[] = $this->db->quoteColumnName($name); if ($value instanceof Expression) { - $placeholders[] = $value->expression; + $lines[] = $this->db->quoteColumnName($name) . '=' . $value->expression; foreach ($value->params as $n => $v) { $params[$n] = $v; } } else { $phName = self::PARAM_PREFIX . count($params); - $placeholders[] = $phName; + $lines[] = $this->db->quoteColumnName($name) . '=' . $phName; $params[$phName] = !is_array($value) && isset($columnSchemas[$name]) ? $columnSchemas[$name]->typecast($value) : $value; } } - return 'INSERT INTO ' . $this->db->quoteTableName($table) - . ' (' . implode(', ', $names) . ') VALUES (' - . implode(', ', $placeholders) . ')'; + $sql = 'UPDATE ' . $this->db->quoteIndexName($index) . ' SET ' . implode(', ', $lines); + $where = $this->buildWhere($condition, $params); + return $where === '' ? $sql : $sql . ' ' . $where; + } + + /** + * Creates a DELETE SQL statement. + * For example, + * + * ~~~ + * $sql = $queryBuilder->delete('tbl_user', 'status = 0'); + * ~~~ + * + * The method will properly escape the index and column names. + * + * @param string $index the index where the data will be deleted from. + * @param array|string $condition the condition that will be put in the WHERE part. Please + * refer to [[Query::where()]] on how to specify condition. + * @param array $params the binding parameters that will be modified by this method + * so that they can be bound to the DB command later. + * @return string the DELETE SQL + */ + public function delete($index, $condition, &$params) + { + $sql = 'DELETE FROM ' . $this->db->quoteIndexName($index); + $where = $this->buildWhere($condition, $params); + return $where === '' ? $sql : $sql . ' ' . $where; + } + + /** + * Builds a SQL statement for truncating a DB index. + * @param string $index the index to be truncated. The name will be properly quoted by the method. + * @return string the SQL statement for truncating a DB index. + */ + public function truncateIndex($index) + { + return 'TRUNCATE RTINDEX ' . $this->db->quoteIndexName($index); + } + + /** + * @param array $columns + * @param boolean $distinct + * @param string $selectOption + * @return string the SELECT clause built from [[query]]. + */ + public function buildSelect($columns, $distinct = false, $selectOption = null) + { + $select = $distinct ? 'SELECT DISTINCT' : 'SELECT'; + if ($selectOption !== null) { + $select .= ' ' . $selectOption; + } + + if (empty($columns)) { + return $select . ' *'; + } + + foreach ($columns as $i => $column) { + if (is_object($column)) { + $columns[$i] = (string)$column; + } elseif (strpos($column, '(') === false) { + if (preg_match('/^(.*?)(?i:\s+as\s+|\s+)([\w\-_\.]+)$/', $column, $matches)) { + $columns[$i] = $this->db->quoteColumnName($matches[1]) . ' AS ' . $this->db->quoteColumnName($matches[2]); + } else { + $columns[$i] = $this->db->quoteColumnName($column); + } + } + } + + if (is_array($columns)) { + $columns = implode(', ', $columns); + } + + return $select . ' ' . $columns; + } + + /** + * @param array $indexes + * @return string the FROM clause built from [[query]]. + */ + public function buildFrom($indexes) + { + if (empty($indexes)) { + return ''; + } + + foreach ($indexes as $i => $index) { + if (strpos($index, '(') === false) { + if (preg_match('/^(.*?)(?i:\s+as|)\s+([^ ]+)$/', $index, $matches)) { // with alias + $indexes[$i] = $this->db->quoteIndexName($matches[1]) . ' ' . $this->db->quoteIndexName($matches[2]); + } else { + $indexes[$i] = $this->db->quoteIndexName($index); + } + } + } + + if (is_array($indexes)) { + $indexes = implode(', ', $indexes); + } + + return 'FROM ' . $indexes; + } + + /** + * @param string|array $condition + * @param array $params the binding parameters to be populated + * @return string the WHERE clause built from [[query]]. + */ + public function buildWhere($condition, &$params) + { + $where = $this->buildCondition($condition, $params); + return $where === '' ? '' : 'WHERE ' . $where; + } + + /** + * @param array $columns + * @return string the GROUP BY clause + */ + public function buildGroupBy($columns) + { + return empty($columns) ? '' : 'GROUP BY ' . $this->buildColumns($columns); + } + + /** + * @param array $columns + * @return string the ORDER BY clause built from [[query]]. + */ + public function buildOrderBy($columns) + { + if (empty($columns)) { + return ''; + } + $orders = []; + foreach ($columns as $name => $direction) { + if (is_object($direction)) { + $orders[] = (string)$direction; + } else { + $orders[] = $this->db->quoteColumnName($name) . ($direction === Query::SORT_DESC ? ' DESC' : ''); + } + } + + return 'ORDER BY ' . implode(', ', $orders); + } + + /** + * @param integer $limit + * @param integer $offset + * @return string the LIMIT and OFFSET clauses built from [[query]]. + */ + public function buildLimit($limit, $offset) + { + $sql = ''; + if ($limit !== null && $limit >= 0) { + $sql = 'LIMIT ' . (int)$limit; + } + if ($offset > 0) { + $sql .= ' OFFSET ' . (int)$offset; + } + return ltrim($sql); + } + + /** + * Processes columns and properly quote them if necessary. + * It will join all columns into a string with comma as separators. + * @param string|array $columns the columns to be processed + * @return string the processing result + */ + public function buildColumns($columns) + { + if (!is_array($columns)) { + if (strpos($columns, '(') !== false) { + return $columns; + } else { + $columns = preg_split('/\s*,\s*/', $columns, -1, PREG_SPLIT_NO_EMPTY); + } + } + foreach ($columns as $i => $column) { + if (is_object($column)) { + $columns[$i] = (string)$column; + } elseif (strpos($column, '(') === false) { + $columns[$i] = $this->db->quoteColumnName($column); + } + } + return is_array($columns) ? implode(', ', $columns) : $columns; + } + + + /** + * Parses the condition specification and generates the corresponding SQL expression. + * @param string|array $condition the condition specification. Please refer to [[Query::where()]] + * on how to specify a condition. + * @param array $params the binding parameters to be populated + * @return string the generated SQL expression + * @throws \yii\db\Exception if the condition is in bad format + */ + public function buildCondition($condition, &$params) + { + static $builders = [ + 'AND' => 'buildAndCondition', + 'OR' => 'buildAndCondition', + 'BETWEEN' => 'buildBetweenCondition', + 'NOT BETWEEN' => 'buildBetweenCondition', + 'IN' => 'buildInCondition', + 'NOT IN' => 'buildInCondition', + 'LIKE' => 'buildLikeCondition', + 'NOT LIKE' => 'buildLikeCondition', + 'OR LIKE' => 'buildLikeCondition', + 'OR NOT LIKE' => 'buildLikeCondition', + ]; + + 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($builders[$operator])) { + $method = $builders[$operator]; + array_shift($condition); + return $this->$method($operator, $condition, $params); + } else { + throw new Exception('Found unknown operator in query: ' . $operator); + } + } else { // hash format: 'column1' => 'value1', 'column2' => 'value2', ... + return $this->buildHashCondition($condition, $params); + } + } + + /** + * Creates a condition based on column-value pairs. + * @param array $condition the condition specification. + * @param array $params the binding parameters to be populated + * @return string the generated SQL expression + */ + public function buildHashCondition($condition, &$params) + { + $parts = []; + foreach ($condition as $column => $value) { + if (is_array($value)) { // IN condition + $parts[] = $this->buildInCondition('IN', [$column, $value], $params); + } else { + if (strpos($column, '(') === false) { + $column = $this->db->quoteColumnName($column); + } + if ($value === null) { + $parts[] = "$column IS NULL"; + } elseif ($value instanceof Expression) { + $parts[] = "$column=" . $value->expression; + foreach ($value->params as $n => $v) { + $params[$n] = $v; + } + } else { + $phName = self::PARAM_PREFIX . count($params); + $parts[] = "$column=$phName"; + $params[$phName] = $value; + } + } + } + return count($parts) === 1 ? $parts[0] : '(' . implode(') AND (', $parts) . ')'; + } + + /** + * Connects two or more SQL expressions with the `AND` or `OR` operator. + * @param string $operator the operator to use for connecting the given operands + * @param array $operands the SQL expressions to connect. + * @param array $params the binding parameters to be populated + * @return string the generated SQL expression + */ + public 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(") $operator (", $parts) . ')'; + } else { + return ''; + } + } + + /** + * Creates an SQL expressions with the `BETWEEN` operator. + * @param string $operator the operator to use (e.g. `BETWEEN` or `NOT BETWEEN`) + * @param array $operands the first operand is the column name. The second and third operands + * describe the interval that column value should be in. + * @param array $params the binding parameters to be populated + * @return string the generated SQL expression + * @throws Exception if wrong number of operands have been given. + */ + public function buildBetweenCondition($operator, $operands, &$params) + { + if (!isset($operands[0], $operands[1], $operands[2])) { + throw new Exception("Operator '$operator' requires three operands."); + } + + list($column, $value1, $value2) = $operands; + + if (strpos($column, '(') === false) { + $column = $this->db->quoteColumnName($column); + } + $phName1 = self::PARAM_PREFIX . count($params); + $params[$phName1] = $value1; + $phName2 = self::PARAM_PREFIX . count($params); + $params[$phName2] = $value2; + + return "$column $operator $phName1 AND $phName2"; + } + + /** + * Creates an SQL expressions 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. + * If it is an empty array the generated expression will be a `false` value if + * operator is `IN` and empty if operator is `NOT IN`. + * @param array $params the binding parameters to be populated + * @return string the generated SQL expression + * @throws Exception if wrong number of operands have been given. + */ + public function buildInCondition($operator, $operands, &$params) + { + if (!isset($operands[0], $operands[1])) { + throw new Exception("Operator '$operator' requires two operands."); + } + + list($column, $values) = $operands; + + $values = (array)$values; + + if (empty($values) || $column === []) { + return $operator === 'IN' ? '0=1' : ''; + } + + if (count($column) > 1) { + return $this->buildCompositeInCondition($operator, $column, $values, $params); + } elseif (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'; + } elseif ($value instanceof Expression) { + $values[$i] = $value->expression; + foreach ($value->params as $n => $v) { + $params[$n] = $v; + } + } else { + $phName = self::PARAM_PREFIX . count($params); + $params[$phName] = $value; + $values[$i] = $phName; + } + } + if (strpos($column, '(') === false) { + $column = $this->db->quoteColumnName($column); + } + + if (count($values) > 1) { + return "$column $operator (" . implode(', ', $values) . ')'; + } else { + $operator = $operator === 'IN' ? '=' : '<>'; + return "$column$operator{$values[0]}"; + } + } + + 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->db->quoteColumnName($column); + } + } + return '(' . implode(', ', $columns) . ") $operator (" . implode(', ', $vss) . ')'; + } + + /** + * Creates an SQL expressions with the `LIKE` operator. + * @param string $operator the operator to use (e.g. `LIKE`, `NOT LIKE`, `OR LIKE` or `OR NOT LIKE`) + * @param array $operands the first operand is the column name. + * The second operand is a single value or an array of values that column value + * should be compared with. + * If it is an empty array the generated expression will be a `false` value if + * operator is `LIKE` or `OR LIKE` and empty if operator is `NOT LIKE` or `OR NOT LIKE`. + * @param array $params the binding parameters to be populated + * @return string the generated SQL expression + * @throws Exception if wrong number of operands have been given. + */ + public function buildLikeCondition($operator, $operands, &$params) + { + if (!isset($operands[0], $operands[1])) { + throw new Exception("Operator '$operator' requires two operands."); + } + + list($column, $values) = $operands; + + $values = (array)$values; + + if (empty($values)) { + return $operator === 'LIKE' || $operator === 'OR LIKE' ? '0=1' : ''; + } + + if ($operator === 'LIKE' || $operator === 'NOT LIKE') { + $andor = ' AND '; + } else { + $andor = ' OR '; + $operator = $operator === 'OR LIKE' ? 'LIKE' : 'NOT LIKE'; + } + + if (strpos($column, '(') === false) { + $column = $this->db->quoteColumnName($column); + } + + $parts = []; + foreach ($values as $value) { + $phName = self::PARAM_PREFIX . count($params); + $params[$phName] = $value; + $parts[] = "$column $operator $phName"; + } + + return implode($andor, $parts); + } + + /** + * @param array $columns + * @return string the ORDER BY clause built from [[query]]. + */ + public function buildWithin($columns) + { + if (empty($columns)) { + return ''; + } + $orders = []; + foreach ($columns as $name => $direction) { + if (is_object($direction)) { + $orders[] = (string)$direction; + } else { + $orders[] = $this->db->quoteColumnName($name) . ($direction === Query::SORT_DESC ? ' DESC' : ''); + } + } + return 'WITHIN GROUP ORDER BY ' . implode(', ', $orders); + } + + /** + * @param array $options + * @return string the OPTION clause build from [[query]] + */ + public function buildOption(array $options) + { + if (empty($options)) { + return ''; + } + $optionLines = []; + foreach ($options as $name => $value) { + $optionLines[] = $name . ' = ' . $value; + } + return 'OPTION ' . implode(', ', $optionLines); } } \ No newline at end of file diff --git a/tests/unit/extensions/sphinx/CommandTest.php b/tests/unit/extensions/sphinx/CommandTest.php index 0a15bf8..32a6ffa 100644 --- a/tests/unit/extensions/sphinx/CommandTest.php +++ b/tests/unit/extensions/sphinx/CommandTest.php @@ -103,7 +103,7 @@ class CommandTest extends SphinxTestCase 'title' => 'Test title', 'content' => 'Test content', 'type_id' => 2, - 'category' => [41, 42], + //'category' => [41, 42], 'id' => 1, ]); $this->assertEquals(1, $command->execute(), 'Unable to execute insert!'); From f4a8be1f6814205975bcd515e3547640c278f890 Mon Sep 17 00:00:00 2001 From: Paul Klimov Date: Tue, 12 Nov 2013 17:07:13 +0200 Subject: [PATCH 12/59] Sphinx MVA insert and update resolved. --- extensions/sphinx/ColumnSchema.php | 2 +- extensions/sphinx/Command.php | 6 +- extensions/sphinx/Connection.php | 16 ++ extensions/sphinx/DataReader.php | 265 +++++++++++++++++++++++++++ extensions/sphinx/QueryBuilder.php | 63 +++++-- extensions/sphinx/Schema.php | 2 +- tests/unit/extensions/sphinx/CommandTest.php | 44 ++++- 7 files changed, 378 insertions(+), 20 deletions(-) create mode 100644 extensions/sphinx/DataReader.php diff --git a/extensions/sphinx/ColumnSchema.php b/extensions/sphinx/ColumnSchema.php index 47eca5f..5edca85 100644 --- a/extensions/sphinx/ColumnSchema.php +++ b/extensions/sphinx/ColumnSchema.php @@ -65,7 +65,7 @@ class ColumnSchema extends Object if ($value === null || gettype($value) === $this->phpType || $value instanceof Expression) { return $value; } - if ($value === '' && $this->type !== Schema::TYPE_TEXT && $this->type !== Schema::TYPE_STRING) { + if ($value === '' && $this->type !== Schema::TYPE_STRING) { return null; } switch ($this->phpType) { diff --git a/extensions/sphinx/Command.php b/extensions/sphinx/Command.php index d22d055..2a0d1e3 100644 --- a/extensions/sphinx/Command.php +++ b/extensions/sphinx/Command.php @@ -10,7 +10,6 @@ namespace yii\sphinx; use Yii; use yii\base\Component; use yii\caching\Cache; -use yii\db\DataReader; use yii\db\Exception; /** @@ -432,8 +431,9 @@ class Command extends Component */ public function batchInsert($index, $columns, $rows) { - $sql = $this->db->getQueryBuilder()->batchInsert($index, $columns, $rows); - return $this->setSql($sql); + $params = []; + $sql = $this->db->getQueryBuilder()->batchInsert($index, $columns, $rows, $params); + return $this->setSql($sql)->bindValues($params); } /** diff --git a/extensions/sphinx/Connection.php b/extensions/sphinx/Connection.php index 59ce4b6..8308005 100644 --- a/extensions/sphinx/Connection.php +++ b/extensions/sphinx/Connection.php @@ -48,4 +48,20 @@ class Connection extends \yii\db\Connection { return $this->getSchema()->quoteIndexName($name); } + + /** + * Creates a command for execution. + * @param string $sql the SQL statement to be executed + * @param array $params the parameters to be bound to the SQL statement + * @return Command the Sphinx command + */ + public function createCommand($sql = null, $params = []) + { + $this->open(); + $command = new Command([ + 'db' => $this, + 'sql' => $sql, + ]); + return $command->bindValues($params); + } } \ No newline at end of file diff --git a/extensions/sphinx/DataReader.php b/extensions/sphinx/DataReader.php new file mode 100644 index 0000000..4b8ffe5 --- /dev/null +++ b/extensions/sphinx/DataReader.php @@ -0,0 +1,265 @@ +query('SELECT * FROM idx_post'); + * + * while ($row = $reader->read()) { + * $rows[] = $row; + * } + * + * // equivalent to: + * foreach ($reader as $row) { + * $rows[] = $row; + * } + * + * // equivalent to: + * $rows = $reader->readAll(); + * ~~~ + * + * Note that since DataReader is a forward-only stream, you can only traverse it once. + * Doing it the second time will throw an exception. + * + * It is possible to use a specific mode of data fetching by setting + * [[fetchMode]]. See the [PHP manual](http://www.php.net/manual/en/function.PDOStatement-setFetchMode.php) + * for more details about possible fetch mode. + * + * @property integer $columnCount The number of columns in the result set. This property is read-only. + * @property integer $fetchMode Fetch mode. This property is write-only. + * @property boolean $isClosed Whether the reader is closed or not. This property is read-only. + * @property integer $rowCount Number of rows contained in the result. This property is read-only. + * + * @author Qiang Xue + * @since 2.0 + */ +class DataReader extends Object implements \Iterator, \Countable +{ + /** + * @var \PDOStatement the PDOStatement associated with the command + */ + private $_statement; + private $_closed = false; + private $_row; + private $_index = -1; + + /** + * Constructor. + * @param Command $command the command generating the query result + * @param array $config name-value pairs that will be used to initialize the object properties + */ + public function __construct(Command $command, $config = []) + { + $this->_statement = $command->pdoStatement; + $this->_statement->setFetchMode(\PDO::FETCH_ASSOC); + parent::__construct($config); + } + + /** + * Binds a column to a PHP variable. + * When rows of data are being fetched, the corresponding column value + * will be set in the variable. Note, the fetch mode must include PDO::FETCH_BOUND. + * @param integer|string $column Number of the column (1-indexed) or name of the column + * in the result set. If using the column name, be aware that the name + * should match the case of the column, as returned by the driver. + * @param mixed $value Name of the PHP variable to which the column will be bound. + * @param integer $dataType Data type of the parameter + * @see http://www.php.net/manual/en/function.PDOStatement-bindColumn.php + */ + public function bindColumn($column, &$value, $dataType = null) + { + if ($dataType === null) { + $this->_statement->bindColumn($column, $value); + } else { + $this->_statement->bindColumn($column, $value, $dataType); + } + } + + /** + * Set the default fetch mode for this statement + * @param integer $mode fetch mode + * @see http://www.php.net/manual/en/function.PDOStatement-setFetchMode.php + */ + public function setFetchMode($mode) + { + $params = func_get_args(); + call_user_func_array([$this->_statement, 'setFetchMode'], $params); + } + + /** + * Advances the reader to the next row in a result set. + * @return array the current row, false if no more row available + */ + public function read() + { + return $this->_statement->fetch(); + } + + /** + * Returns a single column from the next row of a result set. + * @param integer $columnIndex zero-based column index + * @return mixed the column of the current row, false if no more rows available + */ + public function readColumn($columnIndex) + { + return $this->_statement->fetchColumn($columnIndex); + } + + /** + * Returns an object populated with the next row of data. + * @param string $className class name of the object to be created and populated + * @param array $fields Elements of this array are passed to the constructor + * @return mixed the populated object, false if no more row of data available + */ + public function readObject($className, $fields) + { + return $this->_statement->fetchObject($className, $fields); + } + + /** + * Reads the whole result set into an array. + * @return array the result set (each array element represents a row of data). + * An empty array will be returned if the result contains no row. + */ + public function readAll() + { + return $this->_statement->fetchAll(); + } + + /** + * Advances the reader to the next result when reading the results of a batch of statements. + * This method is only useful when there are multiple result sets + * returned by the query. Not all DBMS support this feature. + * @return boolean Returns true on success or false on failure. + */ + public function nextResult() + { + if (($result = $this->_statement->nextRowset()) !== false) { + $this->_index = -1; + } + return $result; + } + + /** + * Closes the reader. + * This frees up the resources allocated for executing this SQL statement. + * Read attempts after this method call are unpredictable. + */ + public function close() + { + $this->_statement->closeCursor(); + $this->_closed = true; + } + + /** + * whether the reader is closed or not. + * @return boolean whether the reader is closed or not. + */ + public function getIsClosed() + { + return $this->_closed; + } + + /** + * Returns the number of rows in the result set. + * Note, most DBMS may not give a meaningful count. + * In this case, use "SELECT COUNT(*) FROM tableName" to obtain the number of rows. + * @return integer number of rows contained in the result. + */ + public function getRowCount() + { + return $this->_statement->rowCount(); + } + + /** + * Returns the number of rows in the result set. + * This method is required by the Countable interface. + * Note, most DBMS may not give a meaningful count. + * In this case, use "SELECT COUNT(*) FROM tableName" to obtain the number of rows. + * @return integer number of rows contained in the result. + */ + public function count() + { + return $this->getRowCount(); + } + + /** + * Returns the number of columns in the result set. + * Note, even there's no row in the reader, this still gives correct column number. + * @return integer the number of columns in the result set. + */ + public function getColumnCount() + { + return $this->_statement->columnCount(); + } + + /** + * Resets the iterator to the initial state. + * This method is required by the interface Iterator. + * @throws InvalidCallException if this method is invoked twice + */ + public function rewind() + { + if ($this->_index < 0) { + $this->_row = $this->_statement->fetch(); + $this->_index = 0; + } else { + throw new InvalidCallException('DataReader cannot rewind. It is a forward-only reader.'); + } + } + + /** + * Returns the index of the current row. + * This method is required by the interface Iterator. + * @return integer the index of the current row. + */ + public function key() + { + return $this->_index; + } + + /** + * Returns the current row. + * This method is required by the interface Iterator. + * @return mixed the current row. + */ + public function current() + { + return $this->_row; + } + + /** + * Moves the internal pointer to the next row. + * This method is required by the interface Iterator. + */ + public function next() + { + $this->_row = $this->_statement->fetch(); + $this->_index++; + } + + /** + * Returns whether there is a row of data at current position. + * This method is required by the interface Iterator. + * @return boolean whether there is a row of data at current position. + */ + public function valid() + { + return $this->_row !== false; + } +} \ No newline at end of file diff --git a/extensions/sphinx/QueryBuilder.php b/extensions/sphinx/QueryBuilder.php index 02f0145..a7bd6be 100644 --- a/extensions/sphinx/QueryBuilder.php +++ b/extensions/sphinx/QueryBuilder.php @@ -103,15 +103,26 @@ class QueryBuilder extends Object $params[$n] = $v; } } else { - $phName = self::PARAM_PREFIX . count($params); - $placeholders[] = $phName; - $params[$phName] = !is_array($value) && isset($columnSchemas[$name]) ? $columnSchemas[$name]->typecast($value) : $value; + if (is_array($value)) { + // MVA : + $placeholderParts = []; + foreach ($value as $subValue) { + $phName = self::PARAM_PREFIX . count($params); + $placeholderParts[] = $phName; + $params[$phName] = isset($columnSchemas[$name]) ? $columnSchemas[$name]->typecast($subValue) : $subValue; + } + $placeholders[] = '(' . implode(',', $placeholderParts) . ')'; + } else { + $phName = self::PARAM_PREFIX . count($params); + $placeholders[] = $phName; + $params[$phName] = isset($columnSchemas[$name]) ? $columnSchemas[$name]->typecast($value) : $value; + } } } return 'INSERT INTO ' . $this->db->quoteIndexName($index) - . ' (' . implode(', ', $names) . ') VALUES (' - . implode(', ', $placeholders) . ')'; + . ' (' . implode(', ', $names) . ') VALUES (' + . implode(', ', $placeholders) . ')'; } /** @@ -131,9 +142,11 @@ class QueryBuilder extends Object * @param string $index the index that new rows will be inserted into. * @param array $columns the column names * @param array $rows the rows to be batch inserted into the index + * @param array $params the binding parameters that will be generated by this method. + * They should be bound to the DB command later. * @return string the batch INSERT SQL statement */ - public function batchInsert($index, $columns, $rows) + public function batchInsert($index, $columns, $rows, &$params) { if (($indexSchema = $this->db->getIndexSchema($index)) !== null) { $columnSchemas = $indexSchema->columns; @@ -149,16 +162,29 @@ class QueryBuilder extends Object foreach ($rows as $row) { $vs = []; foreach ($row as $i => $value) { - if (!is_array($value) && isset($columnSchemas[$columns[$i]])) { - $value = $columnSchemas[$columns[$i]]->typecast($value); + if (is_array($value)) { + // MVA : + $vsParts = []; + foreach ($value as $subValue) { + $phName = self::PARAM_PREFIX . count($params); + $vsParts[] = $phName; + $params[$phName] = isset($columnSchemas[$columns[$i]]) ? $columnSchemas[$columns[$i]]->typecast($subValue) : $subValue; + } + $vs[] = '(' . implode(',', $vsParts) . ')'; + } else { + $phName = self::PARAM_PREFIX . count($params); + if (isset($columnSchemas[$columns[$i]])) { + $value = $columnSchemas[$columns[$i]]->typecast($value); + } + $params[$phName] = is_string($value) ? $this->db->quoteValue($value) : $value; + $vs[] = $phName; } - $vs[] = is_string($value) ? $this->db->quoteValue($value) : $value; } $values[] = '(' . implode(', ', $vs) . ')'; } return 'INSERT INTO ' . $this->db->quoteIndexName($index) - . ' (' . implode(', ', $columns) . ') VALUES ' . implode(', ', $values); + . ' (' . implode(', ', $columns) . ') VALUES ' . implode(', ', $values); } /** @@ -196,9 +222,20 @@ class QueryBuilder extends Object $params[$n] = $v; } } else { - $phName = self::PARAM_PREFIX . count($params); - $lines[] = $this->db->quoteColumnName($name) . '=' . $phName; - $params[$phName] = !is_array($value) && isset($columnSchemas[$name]) ? $columnSchemas[$name]->typecast($value) : $value; + if (is_array($value)) { + // MVA : + $lineParts = []; + foreach ($value as $subValue) { + $phName = self::PARAM_PREFIX . count($params); + $lineParts[] = $phName; + $params[$phName] = isset($columnSchemas[$name]) ? $columnSchemas[$name]->typecast($subValue) : $subValue; + } + $lines[] = $this->db->quoteColumnName($name) . '=' . '(' . implode(',', $lineParts) . ')'; + } else { + $phName = self::PARAM_PREFIX . count($params); + $lines[] = $this->db->quoteColumnName($name) . '=' . $phName; + $params[$phName] = !is_array($value) && isset($columnSchemas[$name]) ? $columnSchemas[$name]->typecast($value) : $value; + } } } diff --git a/extensions/sphinx/Schema.php b/extensions/sphinx/Schema.php index 8313ae4..558f071 100644 --- a/extensions/sphinx/Schema.php +++ b/extensions/sphinx/Schema.php @@ -62,7 +62,7 @@ class Schema extends Object 'timestamp' => self::TYPE_TIMESTAMP, 'bool' => self::TYPE_BOOLEAN, 'float' => self::TYPE_FLOAT, - 'mva' => self::TYPE_STRING, + 'mva' => self::TYPE_INTEGER, ]; /** diff --git a/tests/unit/extensions/sphinx/CommandTest.php b/tests/unit/extensions/sphinx/CommandTest.php index 32a6ffa..40cf895 100644 --- a/tests/unit/extensions/sphinx/CommandTest.php +++ b/tests/unit/extensions/sphinx/CommandTest.php @@ -2,7 +2,7 @@ namespace yiiunit\extensions\sphinx; -use yii\db\DataReader; +use yii\sphinx\DataReader; use yii\db\Expression; /** @@ -103,7 +103,7 @@ class CommandTest extends SphinxTestCase 'title' => 'Test title', 'content' => 'Test content', 'type_id' => 2, - //'category' => [41, 42], + 'category' => [1, 2], 'id' => 1, ]); $this->assertEquals(1, $command->execute(), 'Unable to execute insert!'); @@ -115,6 +115,45 @@ class CommandTest extends SphinxTestCase /** * @depends testInsert */ + public function testBatchInsert() + { + $db = $this->getConnection(); + + $command = $db->createCommand()->batchInsert( + 'yii2_test_rt_index', + [ + 'title', + 'content', + 'type_id', + 'category', + 'id', + ], + [ + [ + 'Test title 1', + 'Test content 1', + 1, + [1, 2], + 1, + ], + [ + 'Test title 2', + 'Test content 2', + 2, + [3, 4], + 2, + ], + ] + ); + $this->assertEquals(2, $command->execute(), 'Unable to execute batch insert!'); + + $rows = $db->createCommand('SELECT * FROM yii2_test_rt_index')->queryAll(); + $this->assertEquals(2, count($rows), 'No rows inserted!'); + } + + /** + * @depends testInsert + */ public function testUpdate() { $db = $this->getConnection(); @@ -131,6 +170,7 @@ class CommandTest extends SphinxTestCase 'yii2_test_rt_index', [ 'type_id' => $newTypeId, + 'category' => [3, 4], ], 'id = 1' ); From 07ad008d49b87e835be3722c4dc7ca2558b96ea6 Mon Sep 17 00:00:00 2001 From: Paul Klimov Date: Wed, 13 Nov 2013 12:23:28 +0200 Subject: [PATCH 13/59] Sphinx unit tests advanced. --- extensions/sphinx/Connection.php | 11 ++++ tests/unit/extensions/sphinx/CommandTest.php | 52 ++++++++++++++++++ tests/unit/extensions/sphinx/SchemaTest.php | 71 +++++++++++++++++++++++++ tests/unit/extensions/sphinx/SphinxTestCase.php | 2 +- 4 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 tests/unit/extensions/sphinx/SchemaTest.php diff --git a/extensions/sphinx/Connection.php b/extensions/sphinx/Connection.php index 8308005..37a4b59 100644 --- a/extensions/sphinx/Connection.php +++ b/extensions/sphinx/Connection.php @@ -10,6 +10,7 @@ namespace yii\sphinx; /** * Class Connection * + * @property Schema $schema The schema information for this Sphinx connection. This property is read-only. * @method Schema getSchema() The schema information for this Sphinx connection * * @author Paul Klimov @@ -50,6 +51,16 @@ class Connection extends \yii\db\Connection } /** + * Alias of [[quoteIndexName()]]. + * @param string $name table name + * @return string the properly quoted table name + */ + public function quoteTableName($name) + { + return $this->quoteIndexName($name); + } + + /** * Creates a command for execution. * @param string $sql the SQL statement to be executed * @param array $params the parameters to be bound to the SQL statement diff --git a/tests/unit/extensions/sphinx/CommandTest.php b/tests/unit/extensions/sphinx/CommandTest.php index 40cf895..ebca709 100644 --- a/tests/unit/extensions/sphinx/CommandTest.php +++ b/tests/unit/extensions/sphinx/CommandTest.php @@ -18,6 +18,58 @@ class CommandTest extends SphinxTestCase // Tests : + public function testConstruct() + { + $db = $this->getConnection(false); + + // null + $command = $db->createCommand(); + $this->assertEquals(null, $command->sql); + + // string + $sql = 'SELECT * FROM yii2_test_item_index'; + $params = [ + 'name' => 'value' + ]; + $command = $db->createCommand($sql, $params); + $this->assertEquals($sql, $command->sql); + $this->assertEquals($params, $command->params); + } + + public function testGetSetSql() + { + $db = $this->getConnection(false); + + $sql = 'SELECT * FROM yii2_test_item_index'; + $command = $db->createCommand($sql); + $this->assertEquals($sql, $command->sql); + + $sql2 = 'SELECT * FROM yii2_test_item_index'; + $command->sql = $sql2; + $this->assertEquals($sql2, $command->sql); + } + + public function testAutoQuoting() + { + $db = $this->getConnection(false); + + $sql = 'SELECT [[id]], [[t.name]] FROM {{yii2_test_item_index}} t'; + $command = $db->createCommand($sql); + $this->assertEquals("SELECT `id`, `t`.`name` FROM `yii2_test_item_index` t", $command->sql); + } + + public function testPrepareCancel() + { + $db = $this->getConnection(false); + + $command = $db->createCommand('SELECT * FROM yii2_test_item_index'); + $this->assertEquals(null, $command->pdoStatement); + $command->prepare(); + $this->assertNotEquals(null, $command->pdoStatement); + $command->cancel(); + $this->assertEquals(null, $command->pdoStatement); + } + public function testExecute() { $db = $this->getConnection(); diff --git a/tests/unit/extensions/sphinx/SchemaTest.php b/tests/unit/extensions/sphinx/SchemaTest.php new file mode 100644 index 0000000..cdb185a --- /dev/null +++ b/tests/unit/extensions/sphinx/SchemaTest.php @@ -0,0 +1,71 @@ +getConnection()->schema; + + $indexes = $schema->getIndexNames(); + $this->assertContains('yii2_test_article_index', $indexes); + $this->assertContains('yii2_test_item_index', $indexes); + $this->assertContains('yii2_test_rt_index', $indexes); + } + + public function testGetIndexSchemas() + { + $schema = $this->getConnection()->schema; + + $indexes = $schema->getTableSchemas(); + $this->assertEquals(count($schema->getIndexNames()), count($indexes)); + foreach($indexes as $index) { + $this->assertInstanceOf('yii\sphinx\IndexSchema', $index); + } + } + + public function testGetNonExistingIndexSchema() + { + $this->assertNull($this->getConnection()->schema->getIndexSchema('non_existing_index')); + } + + public function testSchemaRefresh() + { + $schema = $this->getConnection()->schema; + + $schema->db->enableSchemaCache = true; + $schema->db->schemaCache = new FileCache(); + $noCacheIndex = $schema->getIndexSchema('yii2_test_rt_index', true); + $cachedIndex = $schema->getIndexSchema('yii2_test_rt_index', true); + $this->assertEquals($noCacheIndex, $cachedIndex); + } + + public function testGetPDOType() + { + $values = [ + [null, \PDO::PARAM_NULL], + ['', \PDO::PARAM_STR], + ['hello', \PDO::PARAM_STR], + [0, \PDO::PARAM_INT], + [1, \PDO::PARAM_INT], + [1337, \PDO::PARAM_INT], + [true, \PDO::PARAM_BOOL], + [false, \PDO::PARAM_BOOL], + [$fp=fopen(__FILE__, 'rb'), \PDO::PARAM_LOB], + ]; + + $schema = $this->getConnection()->schema; + + foreach($values as $value) { + $this->assertEquals($value[1], $schema->getPdoType($value[0])); + } + fclose($fp); + } +} \ No newline at end of file diff --git a/tests/unit/extensions/sphinx/SphinxTestCase.php b/tests/unit/extensions/sphinx/SphinxTestCase.php index a77f987..6b936a3 100644 --- a/tests/unit/extensions/sphinx/SphinxTestCase.php +++ b/tests/unit/extensions/sphinx/SphinxTestCase.php @@ -68,7 +68,7 @@ class SphinxTestCase extends TestCase /** * @param bool $reset whether to clean up the test database * @param bool $open whether to open and populate test database - * @return \yii\db\Connection + * @return \yii\sphinx\Connection */ public function getConnection($reset = true, $open = true) { From cc156ba812d0952ccc22e1b82caf2c0e6bb8e73b Mon Sep 17 00:00:00 2001 From: Paul Klimov Date: Wed, 13 Nov 2013 14:04:27 +0200 Subject: [PATCH 14/59] Methods "callSnippet" and "callKeywords" added to "yii\sphinx\Command" --- extensions/sphinx/Command.php | 14 ++++++++++++ extensions/sphinx/Connection.php | 3 +++ extensions/sphinx/QueryBuilder.php | 32 ++++++++++++++++++++++++++++ tests/unit/extensions/sphinx/CommandTest.php | 28 ++++++++++++++++++++++++ 4 files changed, 77 insertions(+) diff --git a/extensions/sphinx/Command.php b/extensions/sphinx/Command.php index 2a0d1e3..a31f79a 100644 --- a/extensions/sphinx/Command.php +++ b/extensions/sphinx/Command.php @@ -495,4 +495,18 @@ class Command extends Component $sql = $this->db->getQueryBuilder()->truncateIndex($index); return $this->setSql($sql); } + + public function callSnippets($index, $source, $query, array $options = []) + { + $params = []; + $sql = $this->db->getQueryBuilder()->callSnippets($index, $source, $query, $options, $params); + return $this->setSql($sql)->bindValues($params); + } + + public function callKeywords($index, $text, $fetchStatistic = false) + { + $params = []; + $sql = $this->db->getQueryBuilder()->callKeywords($index, $text, $fetchStatistic, $params); + return $this->setSql($sql)->bindValues($params); + } } \ No newline at end of file diff --git a/extensions/sphinx/Connection.php b/extensions/sphinx/Connection.php index 37a4b59..bb498d4 100644 --- a/extensions/sphinx/Connection.php +++ b/extensions/sphinx/Connection.php @@ -11,7 +11,10 @@ namespace yii\sphinx; * Class Connection * * @property Schema $schema The schema information for this Sphinx connection. This property is read-only. + * @property QueryBuilder $queryBuilder The query builder for this Sphinx connection. This property is + * read-only. * @method Schema getSchema() The schema information for this Sphinx connection + * @method QueryBuilder getQueryBuilder() he query builder for this Sphinx connection * * @author Paul Klimov * @since 2.0 diff --git a/extensions/sphinx/QueryBuilder.php b/extensions/sphinx/QueryBuilder.php index a7bd6be..f463a0e 100644 --- a/extensions/sphinx/QueryBuilder.php +++ b/extensions/sphinx/QueryBuilder.php @@ -278,6 +278,38 @@ class QueryBuilder extends Object return 'TRUNCATE RTINDEX ' . $this->db->quoteIndexName($index); } + public function callSnippets($index, $source, $query, $options, &$params) + { + if (is_array($source)) { + $dataSqlParts = []; + foreach ($source as $sourceRow) { + $phName = self::PARAM_PREFIX . count($params); + $params[$phName] = $sourceRow; + $dataSqlParts[] = $phName; + } + $dataSql = '(' . implode(',', $dataSqlParts) . ')'; + } else { + $phName = self::PARAM_PREFIX . count($params); + $params[$phName] = $source; + $dataSql = $phName; + } + $indexParamName = self::PARAM_PREFIX . count($params); + $params[$indexParamName] = $index; + $queryParamName = self::PARAM_PREFIX . count($params); + $params[$queryParamName] = $query; + $optionSql = ''; // @todo + return 'CALL SNIPPETS(' . $dataSql. ', ' . $indexParamName . ', ' . $queryParamName . $optionSql. ')'; + } + + public function callKeywords($index, $text, $fetchStatistic, &$params) + { + $indexParamName = self::PARAM_PREFIX . count($params); + $params[$indexParamName] = $index; + $textParamName = self::PARAM_PREFIX . count($params); + $params[$textParamName] = $text; + return 'CALL KEYWORDS(' . $textParamName . ', ' . $indexParamName . ($fetchStatistic ? ', 1' : '') . ')'; + } + /** * @param array $columns * @param boolean $distinct diff --git a/tests/unit/extensions/sphinx/CommandTest.php b/tests/unit/extensions/sphinx/CommandTest.php index ebca709..9183b6c 100644 --- a/tests/unit/extensions/sphinx/CommandTest.php +++ b/tests/unit/extensions/sphinx/CommandTest.php @@ -252,4 +252,32 @@ class CommandTest extends SphinxTestCase $rows = $db->createCommand('SELECT * FROM yii2_test_rt_index')->queryAll(); $this->assertEquals(0, count($rows), 'Unable to delete record!'); } + + public function testCallSnippets() + { + $db = $this->getConnection(); + + $query = 'pencil'; + $data = ['Some data sentence about ' . $query]; + $rows = $db->createCommand()->callSnippets('yii2_test_item_index', $data, $query)->queryColumn(); + $this->assertNotEmpty($rows, 'Unable to call snippets!'); + $this->assertContains('' . $query . '', $rows[0], 'Query not present in the snippet!'); + } + + public function testCallKeywords() + { + $db = $this->getConnection(); + + $text = 'table pencil'; + $rows = $db->createCommand()->callKeywords('yii2_test_item_index', $text)->queryAll(); + $this->assertNotEmpty($rows, 'Unable to call keywords!'); + $this->assertArrayHasKey('tokenized', $rows[0], 'No tokenized keyword!'); + $this->assertArrayHasKey('normalized', $rows[0], 'No normalized keyword!'); + + $text = 'table pencil'; + $rows = $db->createCommand()->callKeywords('yii2_test_item_index', $text, true)->queryAll(); + $this->assertNotEmpty($rows, 'Unable to call keywords with statistic!'); + $this->assertArrayHasKey('docs', $rows[0], 'No docs!'); + $this->assertArrayHasKey('hits', $rows[0], 'No hits!'); + } } \ No newline at end of file From 6b5b99764f2cdecaa787da7b912c421ae7577da6 Mon Sep 17 00:00:00 2001 From: Paul Klimov Date: Wed, 13 Nov 2013 15:52:03 +0200 Subject: [PATCH 15/59] Options support for "yii\sphinx\Command::callSnippet()" added. --- extensions/sphinx/Command.php | 18 +++++++++++++- extensions/sphinx/QueryBuilder.php | 37 +++++++++++++++++++++++++--- tests/unit/extensions/sphinx/CommandTest.php | 22 +++++++++++++++-- 3 files changed, 71 insertions(+), 6 deletions(-) diff --git a/extensions/sphinx/Command.php b/extensions/sphinx/Command.php index a31f79a..c54861f 100644 --- a/extensions/sphinx/Command.php +++ b/extensions/sphinx/Command.php @@ -496,13 +496,29 @@ class Command extends Component return $this->setSql($sql); } - public function callSnippets($index, $source, $query, array $options = []) + /** + * Builds a snippet from provided data and query, using specified index settings. + * @param string $index name of the index, from which to take the text processing settings. + * @param string|array $source is the source data to extract a snippet from. + * It could be either a single string or array of strings. + * @param string $query the full-text query to build snippets for. + * @param array $options list of options in format: optionName => optionValue + * @return static the command object itself + */ + public function callSnippets($index, $source, $query, $options = []) { $params = []; $sql = $this->db->getQueryBuilder()->callSnippets($index, $source, $query, $options, $params); return $this->setSql($sql)->bindValues($params); } + /** + * Returns tokenized and normalized forms of the keywords, and, optionally, keyword statistics. + * @param string $index the name of the index from which to take the text processing settings + * @param string $text the text to break down to keywords. + * @param boolean $fetchStatistic whether to return document and hit occurrence statistics + * @return string the SQL statement for call keywords. + */ public function callKeywords($index, $text, $fetchStatistic = false) { $params = []; diff --git a/extensions/sphinx/QueryBuilder.php b/extensions/sphinx/QueryBuilder.php index f463a0e..e6d110e 100644 --- a/extensions/sphinx/QueryBuilder.php +++ b/extensions/sphinx/QueryBuilder.php @@ -269,15 +269,26 @@ class QueryBuilder extends Object } /** - * Builds a SQL statement for truncating a DB index. + * Builds a SQL statement for truncating an index. * @param string $index the index to be truncated. The name will be properly quoted by the method. - * @return string the SQL statement for truncating a DB index. + * @return string the SQL statement for truncating an index. */ public function truncateIndex($index) { return 'TRUNCATE RTINDEX ' . $this->db->quoteIndexName($index); } + /** + * Builds a SQL statement for call snippet from provided data and query, using specified index settings. + * @param string $index name of the index, from which to take the text processing settings. + * @param string|array $source is the source data to extract a snippet from. + * It could be either a single string or array of strings. + * @param string $query the full-text query to build snippets for. + * @param array $options list of options in format: optionName => optionValue + * @param array $params the binding parameters that will be modified by this method + * so that they can be bound to the Sphinx command later. + * @return string the SQL statement for call snippets. + */ public function callSnippets($index, $source, $query, $options, &$params) { if (is_array($source)) { @@ -297,10 +308,30 @@ class QueryBuilder extends Object $params[$indexParamName] = $index; $queryParamName = self::PARAM_PREFIX . count($params); $params[$queryParamName] = $query; - $optionSql = ''; // @todo + if (!empty($options)) { + $optionParts = []; + foreach ($options as $name => $value) { + $phName = self::PARAM_PREFIX . count($params); + $params[$phName] = $value; + $optionParts[] = $phName . ' AS ' . $name; + } + $optionSql = ', ' . implode(', ', $optionParts); + } else { + $optionSql = ''; + } return 'CALL SNIPPETS(' . $dataSql. ', ' . $indexParamName . ', ' . $queryParamName . $optionSql. ')'; } + /** + * Builds a SQL statement for returning tokenized and normalized forms of the keywords, and, + * optionally, keyword statistics. + * @param string $index the name of the index from which to take the text processing settings + * @param string $text the text to break down to keywords. + * @param boolean $fetchStatistic whether to return document and hit occurrence statistics + * @param array $params the binding parameters that will be modified by this method + * so that they can be bound to the Sphinx command later. + * @return string the SQL statement for call keywords. + */ public function callKeywords($index, $text, $fetchStatistic, &$params) { $indexParamName = self::PARAM_PREFIX . count($params); diff --git a/tests/unit/extensions/sphinx/CommandTest.php b/tests/unit/extensions/sphinx/CommandTest.php index 9183b6c..af34c4f 100644 --- a/tests/unit/extensions/sphinx/CommandTest.php +++ b/tests/unit/extensions/sphinx/CommandTest.php @@ -253,17 +253,35 @@ class CommandTest extends SphinxTestCase $this->assertEquals(0, count($rows), 'Unable to delete record!'); } + /** + * @depends testQuery + */ public function testCallSnippets() { $db = $this->getConnection(); $query = 'pencil'; - $data = ['Some data sentence about ' . $query]; - $rows = $db->createCommand()->callSnippets('yii2_test_item_index', $data, $query)->queryColumn(); + $source = 'Some data sentence about ' . $query; + + $rows = $db->createCommand()->callSnippets('yii2_test_item_index', $source, $query)->queryColumn(); $this->assertNotEmpty($rows, 'Unable to call snippets!'); $this->assertContains('' . $query . '', $rows[0], 'Query not present in the snippet!'); + + $rows = $db->createCommand()->callSnippets('yii2_test_item_index', [$source], $query)->queryColumn(); + $this->assertNotEmpty($rows, 'Unable to call snippets for array source!'); + + $options = [ + 'before_match' => '[', + 'after_match' => ']', + 'limit' => 20, + ]; + $snippet = $db->createCommand()->callSnippets('yii2_test_item_index', $source, $query, $options)->queryScalar(); + $this->assertContains($options['before_match'] . $query . $options['after_match'], $snippet, 'Unable to apply options!'); } + /** + * @depends testQuery + */ public function testCallKeywords() { $db = $this->getConnection(); From 36da1617e8dc27ced3cb8e28dd293944e70cd74d Mon Sep 17 00:00:00 2001 From: Paul Klimov Date: Wed, 13 Nov 2013 17:10:56 +0200 Subject: [PATCH 16/59] "yii\sphinx\Query" has been composed, unit test for it added. --- extensions/sphinx/Query.php | 74 +++++++++++++++- extensions/sphinx/QueryBuilder.php | 25 ++++-- tests/unit/extensions/sphinx/QueryTest.php | 138 +++++++++++++++++++++++++++++ 3 files changed, 226 insertions(+), 11 deletions(-) create mode 100644 tests/unit/extensions/sphinx/QueryTest.php diff --git a/extensions/sphinx/Query.php b/extensions/sphinx/Query.php index 0d76355..dceddb0 100644 --- a/extensions/sphinx/Query.php +++ b/extensions/sphinx/Query.php @@ -30,10 +30,14 @@ class Query extends Component */ const SORT_DESC = true; + /** + * @var array the columns being selected. For example, `['id', 'group_id']`. + * This is used to construct the SELECT clause in a SQL statement. If not set, if means selecting all columns. + * @see select() + */ public $select; /** - * @var string additional option that should be appended to the 'SELECT' keyword. For example, - * in MySQL, the option 'SQL_CALC_FOUND_ROWS' can be used. + * @var string additional option that should be appended to the 'SELECT' keyword. */ public $selectOption; /** @@ -41,15 +45,45 @@ class Query extends Component * the SELECT clause would be changed to SELECT DISTINCT. */ public $distinct; + /** + * @var array the index(es) to be selected from. For example, `['idx_user', 'idx_post']`. + * This is used to construct the FROM clause in a SQL statement. + * @see from() + */ public $from; + /** + * @var string|array query condition. This refers to the WHERE clause in a SQL statement. + * For example, `MATCH('ipod') AND team = 1`. + * @see where() + */ public $where; + /** + * @var integer maximum number of records to be returned. + * Note: if not set implicit LIMIT 0,20 is present by default. + */ public $limit; + /** + * @var integer zero-based offset from where the records are to be returned. If not set or + * less than 0, it means starting from the beginning. + * Note: implicit LIMIT 0,20 is present by default. + */ public $offset; + /** + * @var array how to sort the query results. This is used to construct the ORDER BY clause in a SQL statement. + * The array keys are the columns to be sorted by, and the array values are the corresponding sort directions which + * can be either [[Query::SORT_ASC]] or [[Query::SORT_DESC]]. The array may also contain [[Expression]] objects. + * If that is the case, the expressions will be converted into strings without any change. + */ public $orderBy; + /** + * @var array how to group the query results. For example, `['company', 'department']`. + * This is used to construct the GROUP BY clause in a SQL statement. + */ public $groupBy; /** * @var string WITHIN GROUP ORDER BY clause. This is a Sphinx specific extension * that lets you control how the best row within a group will to be selected. + * The possible value matches the [[orderBy]] one. */ public $within; /** @@ -502,13 +536,25 @@ class Query extends Component return $this; } - public function options(array $options) + /** + * Sets the query options. + * @param array $options query options in format: optionName => optionValue + * @return static the query object itself + * @see addOptions() + */ + public function options($options) { $this->options = $options; return $this; } - public function addOptions(array $options) + /** + * Adds additional query options. + * @param array $options query options in format: optionName => optionValue + * @return static the query object itself + * @see options() + */ + public function addOptions($options) { if (is_array($this->options)) { $this->options = array_merge($this->options, $options); @@ -518,12 +564,32 @@ class Query extends Component return $this; } + /** + * Sets the WITHIN GROUP ORDER BY part of the query. + * @param string|array $columns the columns (and the directions) to find best row within a group. + * Columns can be specified in either a string (e.g. "id ASC, name DESC") or an array + * (e.g. `['id' => Query::SORT_ASC, 'name' => Query::SORT_DESC]`). + * The method will automatically quote the column names unless a column contains some parenthesis + * (which means the column contains a DB expression). + * @return static the query object itself + * @see addWithin() + */ public function within($columns) { $this->within = $this->normalizeOrderBy($columns); return $this; } + /** + * Adds additional WITHIN GROUP ORDER BY columns to the query. + * @param string|array $columns the columns (and the directions) to find best row within a group. + * Columns can be specified in either a string (e.g. "id ASC, name DESC") or an array + * (e.g. `['id' => Query::SORT_ASC, 'name' => Query::SORT_DESC]`). + * The method will automatically quote the column names unless a column contains some parenthesis + * (which means the column contains a DB expression). + * @return static the query object itself + * @see within() + */ public function addWithin($columns) { $columns = $this->normalizeOrderBy($columns); diff --git a/extensions/sphinx/QueryBuilder.php b/extensions/sphinx/QueryBuilder.php index e6d110e..91e3b62 100644 --- a/extensions/sphinx/QueryBuilder.php +++ b/extensions/sphinx/QueryBuilder.php @@ -62,7 +62,7 @@ class QueryBuilder extends Object $this->buildWithin($query->within), $this->buildOrderBy($query->orderBy), $this->buildLimit($query->limit, $query->offset), - $this->buildOption($query->options), + $this->buildOption($query->options, $params), ]; return [implode($this->separator, array_filter($clauses)), $params]; } @@ -311,9 +311,13 @@ class QueryBuilder extends Object if (!empty($options)) { $optionParts = []; foreach ($options as $name => $value) { - $phName = self::PARAM_PREFIX . count($params); - $params[$phName] = $value; - $optionParts[] = $phName . ' AS ' . $name; + if ($value instanceof Expression) { + $actualValue = $value->expression; + } else { + $actualValue = self::PARAM_PREFIX . count($params); + $params[$actualValue] = $value; + } + $optionParts[] = $actualValue . ' AS ' . $name; } $optionSql = ', ' . implode(', ', $optionParts); } else { @@ -768,17 +772,24 @@ class QueryBuilder extends Object } /** - * @param array $options + * @param array $options query options in format: optionName => optionValue + * @param array $params the binding parameters to be populated * @return string the OPTION clause build from [[query]] */ - public function buildOption(array $options) + public function buildOption($options, &$params) { if (empty($options)) { return ''; } $optionLines = []; foreach ($options as $name => $value) { - $optionLines[] = $name . ' = ' . $value; + if ($value instanceof Expression) { + $actualValue = $value->expression; + } else { + $actualValue = self::PARAM_PREFIX . count($params); + $params[$actualValue] = $value; + } + $optionLines[] = $name . ' = ' . $actualValue; } return 'OPTION ' . implode(', ', $optionLines); } diff --git a/tests/unit/extensions/sphinx/QueryTest.php b/tests/unit/extensions/sphinx/QueryTest.php new file mode 100644 index 0000000..36bf95d --- /dev/null +++ b/tests/unit/extensions/sphinx/QueryTest.php @@ -0,0 +1,138 @@ +select('*'); + $this->assertEquals(['*'], $query->select); + $this->assertNull($query->distinct); + $this->assertEquals(null, $query->selectOption); + + $query = new Query; + $query->select('id, name', 'something')->distinct(true); + $this->assertEquals(['id', 'name'], $query->select); + $this->assertTrue($query->distinct); + $this->assertEquals('something', $query->selectOption); + } + + public function testFrom() + { + $query = new Query; + $query->from('tbl_user'); + $this->assertEquals(['tbl_user'], $query->from); + } + + public function testWhere() + { + $query = new Query; + $query->where('id = :id', [':id' => 1]); + $this->assertEquals('id = :id', $query->where); + $this->assertEquals([':id' => 1], $query->params); + + $query->andWhere('name = :name', [':name' => 'something']); + $this->assertEquals(['and', 'id = :id', 'name = :name'], $query->where); + $this->assertEquals([':id' => 1, ':name' => 'something'], $query->params); + + $query->orWhere('age = :age', [':age' => '30']); + $this->assertEquals(['or', ['and', 'id = :id', 'name = :name'], 'age = :age'], $query->where); + $this->assertEquals([':id' => 1, ':name' => 'something', ':age' => '30'], $query->params); + } + + public function testGroup() + { + $query = new Query; + $query->groupBy('team'); + $this->assertEquals(['team'], $query->groupBy); + + $query->addGroupBy('company'); + $this->assertEquals(['team', 'company'], $query->groupBy); + + $query->addGroupBy('age'); + $this->assertEquals(['team', 'company', 'age'], $query->groupBy); + } + + public function testOrder() + { + $query = new Query; + $query->orderBy('team'); + $this->assertEquals(['team' => false], $query->orderBy); + + $query->addOrderBy('company'); + $this->assertEquals(['team' => false, 'company' => false], $query->orderBy); + + $query->addOrderBy('age'); + $this->assertEquals(['team' => false, 'company' => false, 'age' => false], $query->orderBy); + + $query->addOrderBy(['age' => true]); + $this->assertEquals(['team' => false, 'company' => false, 'age' => true], $query->orderBy); + + $query->addOrderBy('age ASC, company DESC'); + $this->assertEquals(['team' => false, 'company' => true, 'age' => false], $query->orderBy); + } + + public function testLimitOffset() + { + $query = new Query; + $query->limit(10)->offset(5); + $this->assertEquals(10, $query->limit); + $this->assertEquals(5, $query->offset); + } + + public function testWithin() + { + $query = new Query; + $query->within('team'); + $this->assertEquals(['team' => false], $query->within); + + $query->addWithin('company'); + $this->assertEquals(['team' => false, 'company' => false], $query->within); + + $query->addWithin('age'); + $this->assertEquals(['team' => false, 'company' => false, 'age' => false], $query->within); + + $query->addWithin(['age' => true]); + $this->assertEquals(['team' => false, 'company' => false, 'age' => true], $query->within); + + $query->addWithin('age ASC, company DESC'); + $this->assertEquals(['team' => false, 'company' => true, 'age' => false], $query->within); + } + + public function testOptions() + { + $query = new Query; + $options = [ + 'cutoff' => 50, + 'max_matches' => 50, + ]; + $query->options($options); + $this->assertEquals($options, $query->options); + + $newMaxMatches = $options['max_matches'] + 10; + $query->addOptions(['max_matches' => $newMaxMatches]); + $this->assertEquals($newMaxMatches, $query->options['max_matches']); + } + + public function testRun() + { + $connection = $this->getConnection(); + + $query = new Query; + $rows = $query->from('yii2_test_article_index') + ->where("MATCH('about')") + ->options([ + 'cutoff' => 50, + ]) + ->all($connection); + $this->assertNotEmpty($rows); + } +} \ No newline at end of file From 0839ceb598510dcc9506132b875cb95dff0abccc Mon Sep 17 00:00:00 2001 From: Klimov Paul Date: Wed, 13 Nov 2013 20:04:07 +0200 Subject: [PATCH 17/59] Parameter "options" added to "yii\sphinx\Command::update()" --- extensions/sphinx/Command.php | 5 +++-- extensions/sphinx/Connection.php | 2 +- extensions/sphinx/QueryBuilder.php | 12 +++++++++-- tests/unit/extensions/sphinx/CommandTest.php | 30 ++++++++++++++++++++++++++++ 4 files changed, 44 insertions(+), 5 deletions(-) diff --git a/extensions/sphinx/Command.php b/extensions/sphinx/Command.php index c54861f..cab9487 100644 --- a/extensions/sphinx/Command.php +++ b/extensions/sphinx/Command.php @@ -453,11 +453,12 @@ class Command extends Component * @param string|array $condition the condition that will be put in the WHERE part. Please * refer to [[Query::where()]] on how to specify condition. * @param array $params the parameters to be bound to the command + * @param array $options list of options in format: optionName => optionValue * @return static the command object itself */ - public function update($index, $columns, $condition = '', $params = []) + public function update($index, $columns, $condition = '', $params = [], $options = []) { - $sql = $this->db->getQueryBuilder()->update($index, $columns, $condition, $params); + $sql = $this->db->getQueryBuilder()->update($index, $columns, $condition, $params, $options); return $this->setSql($sql)->bindValues($params); } diff --git a/extensions/sphinx/Connection.php b/extensions/sphinx/Connection.php index bb498d4..6009f1f 100644 --- a/extensions/sphinx/Connection.php +++ b/extensions/sphinx/Connection.php @@ -14,7 +14,7 @@ namespace yii\sphinx; * @property QueryBuilder $queryBuilder The query builder for this Sphinx connection. This property is * read-only. * @method Schema getSchema() The schema information for this Sphinx connection - * @method QueryBuilder getQueryBuilder() he query builder for this Sphinx connection + * @method QueryBuilder getQueryBuilder() the query builder for this Sphinx connection * * @author Paul Klimov * @since 2.0 diff --git a/extensions/sphinx/QueryBuilder.php b/extensions/sphinx/QueryBuilder.php index 91e3b62..997e997 100644 --- a/extensions/sphinx/QueryBuilder.php +++ b/extensions/sphinx/QueryBuilder.php @@ -204,9 +204,10 @@ class QueryBuilder extends Object * refer to [[Query::where()]] on how to specify condition. * @param array $params the binding parameters that will be modified by this method * so that they can be bound to the DB command later. + * @param array $options list of options in format: optionName => optionValue * @return string the UPDATE SQL */ - public function update($index, $columns, $condition, &$params) + public function update($index, $columns, $condition, &$params, $options) { if (($indexSchema = $this->db->getIndexSchema($index)) !== null) { $columnSchemas = $indexSchema->columns; @@ -241,7 +242,14 @@ class QueryBuilder extends Object $sql = 'UPDATE ' . $this->db->quoteIndexName($index) . ' SET ' . implode(', ', $lines); $where = $this->buildWhere($condition, $params); - return $where === '' ? $sql : $sql . ' ' . $where; + if ($where !== '') { + $sql = $sql . ' ' . $where; + } + $option = $this->buildOption($options, $params); + if ($option !== '') { + $sql = $sql . ' ' . $option; + } + return $sql; } /** diff --git a/tests/unit/extensions/sphinx/CommandTest.php b/tests/unit/extensions/sphinx/CommandTest.php index af34c4f..5986182 100644 --- a/tests/unit/extensions/sphinx/CommandTest.php +++ b/tests/unit/extensions/sphinx/CommandTest.php @@ -233,6 +233,36 @@ class CommandTest extends SphinxTestCase } /** + * @depends testUpdate + */ + public function testUpdateWithOptions() + { + $db = $this->getConnection(); + + $db->createCommand()->insert('yii2_test_rt_index', [ + 'title' => 'Test title', + 'content' => 'Test content', + 'type_id' => 2, + 'id' => 1, + ])->execute(); + + $newTypeId = 5; + $command = $db->createCommand()->update( + 'yii2_test_rt_index', + [ + 'type_id' => $newTypeId, + 'non_existing_attribute' => 10, + ], + 'id = 1', + [], + [ + 'ignore_nonexistent_columns' => 1 + ] + ); + $this->assertEquals(1, $command->execute(), 'Unable to execute update!'); + } + + /** * @depends testInsert */ public function testDelete() From aae221ddea56cedfba397887fdf63eb0d1cc2cb2 Mon Sep 17 00:00:00 2001 From: Klimov Paul Date: Wed, 13 Nov 2013 20:33:32 +0200 Subject: [PATCH 18/59] Methods "replace()" and "batchReplace()" added to "yii\sphinx\Command" --- extensions/sphinx/Command.php | 52 +++++++++++++++++ extensions/sphinx/QueryBuilder.php | 83 +++++++++++++++++++++++++++- tests/unit/extensions/sphinx/CommandTest.php | 79 ++++++++++++++++++++++++++ 3 files changed, 211 insertions(+), 3 deletions(-) diff --git a/extensions/sphinx/Command.php b/extensions/sphinx/Command.php index cab9487..2341d98 100644 --- a/extensions/sphinx/Command.php +++ b/extensions/sphinx/Command.php @@ -437,6 +437,58 @@ class Command extends Component } /** + * Creates an REPLACE command. + * For example, + * + * ~~~ + * $connection->createCommand()->insert('idx_user', [ + * 'name' => 'Sam', + * 'age' => 30, + * ])->execute(); + * ~~~ + * + * The method will properly escape the column names, and bind the values to be replaced. + * + * Note that the created command is not executed until [[execute()]] is called. + * + * @param string $index the index that new rows will be replaced into. + * @param array $columns the column data (name => value) to be replaced into the index. + * @return static the command object itself + */ + public function replace($index, $columns) + { + $params = []; + $sql = $this->db->getQueryBuilder()->replace($index, $columns, $params); + return $this->setSql($sql)->bindValues($params); + } + + /** + * Creates a batch REPLACE command. + * For example, + * + * ~~~ + * $connection->createCommand()->batchInsert('idx_user', ['name', 'age'], [ + * ['Tom', 30], + * ['Jane', 20], + * ['Linda', 25], + * ])->execute(); + * ~~~ + * + * Note that the values in each row must match the corresponding column names. + * + * @param string $index the index that new rows will be replaced. + * @param array $columns the column names + * @param array $rows the rows to be batch replaced in the index + * @return static the command object itself + */ + public function batchReplace($index, $columns, $rows) + { + $params = []; + $sql = $this->db->getQueryBuilder()->batchReplace($index, $columns, $rows, $params); + return $this->setSql($sql)->bindValues($params); + } + + /** * Creates an UPDATE command. * For example, * diff --git a/extensions/sphinx/QueryBuilder.php b/extensions/sphinx/QueryBuilder.php index 997e997..860e629 100644 --- a/extensions/sphinx/QueryBuilder.php +++ b/extensions/sphinx/QueryBuilder.php @@ -83,11 +83,48 @@ class QueryBuilder extends Object * @param string $index the index that new rows will be inserted into. * @param array $columns the column data (name => value) to be inserted into the index. * @param array $params the binding parameters that will be generated by this method. - * They should be bound to the DB command later. + * They should be bound to the Sphinx command later. * @return string the INSERT SQL */ public function insert($index, $columns, &$params) { + return $this->generateInsertReplace('INSERT', $index, $columns, $params); + } + + /** + * Creates an REPLACE SQL statement. + * For example, + * + * ~~~ + * $sql = $queryBuilder->replace('idx_user', [ + * 'name' => 'Sam', + * 'age' => 30, + * ], $params); + * ~~~ + * + * The method will properly escape the index and column names. + * + * @param string $index the index that new rows will be replaced. + * @param array $columns the column data (name => value) to be replaced in the index. + * @param array $params the binding parameters that will be generated by this method. + * They should be bound to the Sphinx command later. + * @return string the INSERT SQL + */ + public function replace($index, $columns, &$params) + { + return $this->generateInsertReplace('REPLACE', $index, $columns, $params); + } + + /** + * Generates INSERT/REPLACE SQL statement. + * @param string $statement statement ot be generated. + * @param string $index the affected index name. + * @param array $columns the column data (name => value). + * @param array $params the binding parameters that will be generated by this method. + * @return string generated SQL + */ + protected function generateInsertReplace($statement, $index, $columns, &$params) + { if (($indexSchema = $this->db->getIndexSchema($index)) !== null) { $columnSchemas = $indexSchema->columns; } else { @@ -120,7 +157,7 @@ class QueryBuilder extends Object } } - return 'INSERT INTO ' . $this->db->quoteIndexName($index) + return $statement . ' INTO ' . $this->db->quoteIndexName($index) . ' (' . implode(', ', $names) . ') VALUES (' . implode(', ', $placeholders) . ')'; } @@ -148,6 +185,46 @@ class QueryBuilder extends Object */ public function batchInsert($index, $columns, $rows, &$params) { + return $this->generateBatchInsertReplace('INSERT', $index, $columns, $rows, $params); + } + + /** + * Generates a batch REPLACE SQL statement. + * For example, + * + * ~~~ + * $connection->createCommand()->batchReplace('idx_user', ['name', 'age'], [ + * ['Tom', 30], + * ['Jane', 20], + * ['Linda', 25], + * ])->execute(); + * ~~~ + * + * Note that the values in each row must match the corresponding column names. + * + * @param string $index the index that new rows will be replaced. + * @param array $columns the column names + * @param array $rows the rows to be batch replaced in the index + * @param array $params the binding parameters that will be generated by this method. + * They should be bound to the DB command later. + * @return string the batch INSERT SQL statement + */ + public function batchReplace($index, $columns, $rows, &$params) + { + return $this->generateBatchInsertReplace('REPLACE', $index, $columns, $rows, $params); + } + + /** + * Generates a batch INSERT/REPLACE SQL statement. + * @param string $statement statement ot be generated. + * @param string $index the affected index name. + * @param array $columns the column data (name => value). + * @param array $rows the rows to be batch inserted into the index + * @param array $params the binding parameters that will be generated by this method. + * @return string generated SQL + */ + protected function generateBatchInsertReplace($statement, $index, $columns, $rows, &$params) + { if (($indexSchema = $this->db->getIndexSchema($index)) !== null) { $columnSchemas = $indexSchema->columns; } else { @@ -183,7 +260,7 @@ class QueryBuilder extends Object $values[] = '(' . implode(', ', $vs) . ')'; } - return 'INSERT INTO ' . $this->db->quoteIndexName($index) + return $statement . ' INTO ' . $this->db->quoteIndexName($index) . ' (' . implode(', ', $columns) . ') VALUES ' . implode(', ', $values); } diff --git a/tests/unit/extensions/sphinx/CommandTest.php b/tests/unit/extensions/sphinx/CommandTest.php index 5986182..e83fad2 100644 --- a/tests/unit/extensions/sphinx/CommandTest.php +++ b/tests/unit/extensions/sphinx/CommandTest.php @@ -206,6 +206,85 @@ class CommandTest extends SphinxTestCase /** * @depends testInsert */ + public function testReplace() + { + $db = $this->getConnection(); + + $command = $db->createCommand()->replace('yii2_test_rt_index', [ + 'title' => 'Test title', + 'content' => 'Test content', + 'type_id' => 2, + 'category' => [1, 2], + 'id' => 1, + ]); + $this->assertEquals(1, $command->execute(), 'Unable to execute replace!'); + + $rows = $db->createCommand('SELECT * FROM yii2_test_rt_index')->queryAll(); + $this->assertEquals(1, count($rows), 'No row inserted!'); + + $newTypeId = 5; + $command = $db->createCommand()->replace('yii2_test_rt_index',[ + 'type_id' => $newTypeId, + 'category' => [3, 4], + 'id' => 1, + ]); + $this->assertEquals(1, $command->execute(), 'Unable to update via replace!'); + + list($row) = $db->createCommand('SELECT * FROM yii2_test_rt_index')->queryAll(); + $this->assertEquals($newTypeId, $row['type_id'], 'Unable to update attribute value!'); + } + + /** + * @depends testReplace + */ + public function testBatchReplace() + { + $db = $this->getConnection(); + + $command = $db->createCommand()->batchReplace( + 'yii2_test_rt_index', + [ + 'title', + 'content', + 'type_id', + 'category', + 'id', + ], + [ + [ + 'Test title 1', + 'Test content 1', + 1, + [1, 2], + 1, + ], + [ + 'Test title 2', + 'Test content 2', + 2, + [3, 4], + 2, + ], + ] + ); + $this->assertEquals(2, $command->execute(), 'Unable to execute batch replace!'); + + $rows = $db->createCommand('SELECT * FROM yii2_test_rt_index')->queryAll(); + $this->assertEquals(2, count($rows), 'No rows inserted!'); + + $newTypeId = 5; + $command = $db->createCommand()->replace('yii2_test_rt_index',[ + 'type_id' => $newTypeId, + 'id' => 1, + ]); + $this->assertEquals(1, $command->execute(), 'Unable to update via replace!'); + list($row) = $db->createCommand('SELECT * FROM yii2_test_rt_index')->queryAll(); + $this->assertEquals($newTypeId, $row['type_id'], 'Unable to update attribute value!'); + } + + /** + * @depends testInsert + */ public function testUpdate() { $db = $this->getConnection(); From 0275a2244f71730ed015d19d6e15f9319b7788e0 Mon Sep 17 00:00:00 2001 From: Klimov Paul Date: Wed, 13 Nov 2013 20:48:05 +0200 Subject: [PATCH 19/59] Option array value support added to "yii\sphinx\Query" --- extensions/sphinx/QueryBuilder.php | 24 ++++++++++++++++++++++-- tests/unit/extensions/sphinx/QueryTest.php | 4 ++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/extensions/sphinx/QueryBuilder.php b/extensions/sphinx/QueryBuilder.php index 860e629..ef3628d 100644 --- a/extensions/sphinx/QueryBuilder.php +++ b/extensions/sphinx/QueryBuilder.php @@ -871,8 +871,28 @@ class QueryBuilder extends Object if ($value instanceof Expression) { $actualValue = $value->expression; } else { - $actualValue = self::PARAM_PREFIX . count($params); - $params[$actualValue] = $value; + if (is_array($value)) { + $actualValueParts = []; + foreach ($value as $key => $valuePart) { + if (is_numeric($key)) { + $actualValuePart = ''; + } else { + $actualValuePart = $key . ' = '; + } + if ($valuePart instanceof Expression) { + $actualValuePart .= $valuePart->expression; + } else { + $phName = self::PARAM_PREFIX . count($params); + $params[$phName] = $valuePart; + $actualValuePart .= $phName; + } + $actualValueParts[] = $actualValuePart; + } + $actualValue = '(' . implode(', ', $actualValueParts) . ')'; + } else { + $actualValue = self::PARAM_PREFIX . count($params); + $params[$actualValue] = $value; + } } $optionLines[] = $name . ' = ' . $actualValue; } diff --git a/tests/unit/extensions/sphinx/QueryTest.php b/tests/unit/extensions/sphinx/QueryTest.php index 36bf95d..3a7d2c3 100644 --- a/tests/unit/extensions/sphinx/QueryTest.php +++ b/tests/unit/extensions/sphinx/QueryTest.php @@ -131,6 +131,10 @@ class QueryTest extends SphinxTestCase ->where("MATCH('about')") ->options([ 'cutoff' => 50, + 'field_weights' => [ + 'title' => 10, + 'content' => 3, + ], ]) ->all($connection); $this->assertNotEmpty($rows); From 47a2e211d66f8cc942ab5059e9e02790576a5cb6 Mon Sep 17 00:00:00 2001 From: Klimov Paul Date: Thu, 14 Nov 2013 19:15:18 +0200 Subject: [PATCH 20/59] Separated method "match" added to "yii\sphinx\Query" --- extensions/sphinx/Query.php | 88 +++++++++++++++++++++++++++++- extensions/sphinx/QueryBuilder.php | 5 ++ tests/unit/extensions/sphinx/QueryTest.php | 10 +++- 3 files changed, 101 insertions(+), 2 deletions(-) diff --git a/extensions/sphinx/Query.php b/extensions/sphinx/Query.php index dceddb0..ecb5388 100644 --- a/extensions/sphinx/Query.php +++ b/extensions/sphinx/Query.php @@ -52,8 +52,13 @@ class Query extends Component */ public $from; /** + * @var string text, which should be searched in fulltext mode. + * This value will be composed into MATCH operator inside the WHERE clause. + */ + public $match; + /** * @var string|array query condition. This refers to the WHERE clause in a SQL statement. - * For example, `MATCH('ipod') AND team = 1`. + * For example, `group_id > 5 AND team = 1`. * @see where() */ public $where; @@ -332,6 +337,87 @@ class Query extends Component return $this; } + /** + * Sets the fulltext query text. This text will be composed into + * MATCH operator inside the WHERE clause. + * @param string $query fulltext query text. + * @return static the query object itself + */ + public function match($query) + { + $this->match = $query; + return $this; + } + + /** + * Sets the WHERE part of the query. + * + * The method requires a $condition parameter, and optionally a $params parameter + * specifying the values to be bound to the query. + * + * The $condition parameter should be either a string (e.g. 'id=1') or an array. + * If the latter, it must be in one of the following two formats: + * + * - hash format: `['column1' => value1, 'column2' => value2, ...]` + * - operator format: `[operator, operand1, operand2, ...]` + * + * A condition in hash format represents the following SQL expression in general: + * `column1=value1 AND column2=value2 AND ...`. In case when a value is an array, + * an `IN` expression will be generated. And if a value is null, `IS NULL` will be used + * in the generated expression. Below are some examples: + * + * - `['type' => 1, 'status' => 2]` generates `(type = 1) AND (status = 2)`. + * - `['id' => [1, 2, 3], 'status' => 2]` generates `(id IN (1, 2, 3)) AND (status = 2)`. + * - `['status' => null] generates `status IS NULL`. + * + * A condition in operator format generates the SQL expression according to the specified operator, which + * can be one of the followings: + * + * - `and`: the operands should be concatenated together using `AND`. For example, + * `['and', 'id=1', 'id=2']` will generate `id=1 AND id=2`. If an operand is an array, + * it will be converted into a string using the rules described here. For example, + * `['and', 'type=1', ['or', 'id=1', 'id=2']]` will generate `type=1 AND (id=1 OR id=2)`. + * The method will NOT do any quoting or escaping. + * + * - `or`: similar to the `and` operator except that the operands are concatenated using `OR`. + * + * - `between`: operand 1 should be the column name, and operand 2 and 3 should be the + * starting and ending values of the range that the column is in. + * For example, `['between', 'id', 1, 10]` will generate `id BETWEEN 1 AND 10`. + * + * - `not between`: similar to `between` except the `BETWEEN` is replaced with `NOT BETWEEN` + * in the generated condition. + * + * - `in`: operand 1 should be a column or DB expression, and operand 2 be an array representing + * the range of the values that the column or DB expression should be in. For example, + * `['in', 'id', [1, 2, 3]]` will generate `id IN (1, 2, 3)`. + * The method will properly quote the column name and escape values in the range. + * + * - `not in`: similar to the `in` operator except that `IN` is replaced with `NOT IN` in the generated condition. + * + * - `like`: operand 1 should be a column or DB expression, and operand 2 be a string or an array representing + * the values that the column or DB expression should be like. + * For example, `['like', 'name', '%tester%']` will generate `name LIKE '%tester%'`. + * When the value range is given as an array, multiple `LIKE` predicates will be generated and concatenated + * using `AND`. For example, `['like', 'name', ['%test%', '%sample%']]` will generate + * `name LIKE '%test%' AND name LIKE '%sample%'`. + * The method will properly quote the column name and escape values in the range. + * + * - `or like`: similar to the `like` operator except that `OR` is used to concatenate the `LIKE` + * predicates when operand 2 is an array. + * + * - `not like`: similar to the `like` operator except that `LIKE` is replaced with `NOT LIKE` + * in the generated condition. + * + * - `or not like`: similar to the `not like` operator except that `OR` is used to concatenate + * the `NOT LIKE` predicates. + * + * @param string|array $condition the conditions that should be put in the WHERE part. + * @param array $params the parameters (name => value) to be bound to the query. + * @return static the query object itself + * @see andWhere() + * @see orWhere() + */ public function where($condition, $params = []) { $this->where = $condition; diff --git a/extensions/sphinx/QueryBuilder.php b/extensions/sphinx/QueryBuilder.php index ef3628d..a1c572b 100644 --- a/extensions/sphinx/QueryBuilder.php +++ b/extensions/sphinx/QueryBuilder.php @@ -54,6 +54,11 @@ class QueryBuilder extends Object public function build($query) { $params = $query->params; + if ($query->match !== null) { + $phName = self::PARAM_PREFIX . count($params); + $params[$phName] = (string)$query->match; + $query->andWhere('MATCH(' . $phName . ')'); + } $clauses = [ $this->buildSelect($query->select, $query->distinct, $query->selectOption), $this->buildFrom($query->from), diff --git a/tests/unit/extensions/sphinx/QueryTest.php b/tests/unit/extensions/sphinx/QueryTest.php index 3a7d2c3..cc52e74 100644 --- a/tests/unit/extensions/sphinx/QueryTest.php +++ b/tests/unit/extensions/sphinx/QueryTest.php @@ -32,6 +32,14 @@ class QueryTest extends SphinxTestCase $this->assertEquals(['tbl_user'], $query->from); } + public function testMatch() + { + $query = new Query; + $match = 'test match'; + $query->match($match); + $this->assertEquals($match, $query->match); + } + public function testWhere() { $query = new Query; @@ -128,7 +136,7 @@ class QueryTest extends SphinxTestCase $query = new Query; $rows = $query->from('yii2_test_article_index') - ->where("MATCH('about')") + ->match('about') ->options([ 'cutoff' => 50, 'field_weights' => [ From 9459b44022215bbd7dfc59a3b252f5a21b3c0865 Mon Sep 17 00:00:00 2001 From: Klimov Paul Date: Thu, 14 Nov 2013 19:27:21 +0200 Subject: [PATCH 21/59] Composite primary key support removed from Sphinx schema. --- extensions/sphinx/IndexSchema.php | 27 ++------------------------- extensions/sphinx/Schema.php | 8 ++++---- 2 files changed, 6 insertions(+), 29 deletions(-) diff --git a/extensions/sphinx/IndexSchema.php b/extensions/sphinx/IndexSchema.php index 81ba075..3ce090d 100644 --- a/extensions/sphinx/IndexSchema.php +++ b/extensions/sphinx/IndexSchema.php @@ -29,9 +29,9 @@ class IndexSchema extends Object */ public $name; /** - * @var string[] primary keys of this index. + * @var string primary key of this index. */ - public $primaryKey = []; + public $primaryKey; /** * @var ColumnSchema[] column metadata of this index. Each array element is a [[ColumnSchema]] object, indexed by column names. */ @@ -56,27 +56,4 @@ class IndexSchema extends Object { return array_keys($this->columns); } - - /** - * Manually specifies the primary key for this table. - * @param string|array $keys the primary key (can be composite) - * @throws InvalidParamException if the specified key cannot be found in the table. - */ - public function fixPrimaryKey($keys) - { - if (!is_array($keys)) { - $keys = [$keys]; - } - $this->primaryKey = $keys; - foreach ($this->columns as $column) { - $column->isPrimaryKey = false; - } - foreach ($keys as $key) { - if (isset($this->columns[$key])) { - $this->columns[$key]->isPrimaryKey = true; - } else { - throw new InvalidParamException("Primary key '$key' cannot be found in index '{$this->name}'."); - } - } - } } \ No newline at end of file diff --git a/extensions/sphinx/Schema.php b/extensions/sphinx/Schema.php index 558f071..20675b2 100644 --- a/extensions/sphinx/Schema.php +++ b/extensions/sphinx/Schema.php @@ -70,7 +70,7 @@ class Schema extends Object * @param string $name index name * @return IndexSchema driver dependent index metadata. Null if the index does not exist. */ - protected function loadTableSchema($name) + protected function loadIndexSchema($name) { $index = new IndexSchema; $this->resolveIndexNames($index, $name); @@ -119,7 +119,7 @@ class Schema extends Object if ($cache instanceof Cache) { $key = $this->getCacheKey($name); if ($refresh || ($index = $cache->get($key)) === false) { - $index = $this->loadTableSchema($realName); + $index = $this->loadIndexSchema($realName); if ($index !== null) { $cache->set($key, $index, $db->schemaCacheDuration, new GroupDependency($this->getCacheGroup())); } @@ -127,7 +127,7 @@ class Schema extends Object return $this->_indexes[$name] = $index; } } - return $this->_indexes[$name] = $index = $this->loadTableSchema($realName); + return $this->_indexes[$name] = $index = $this->loadIndexSchema($realName); } /** @@ -424,7 +424,7 @@ class Schema extends Object $column = $this->loadColumnSchema($info); $index->columns[$column->name] = $column; if ($column->isPrimaryKey) { - $index->primaryKey[] = $column->name; + $index->primaryKey = $column->name; } } return true; From 6593c8cea47f37c88f3835dbe5e77b76fe972319 Mon Sep 17 00:00:00 2001 From: Klimov Paul Date: Thu, 14 Nov 2013 19:39:08 +0200 Subject: [PATCH 22/59] Unit test for "yii\sphinx\Query" advanced --- tests/unit/extensions/sphinx/QueryTest.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/unit/extensions/sphinx/QueryTest.php b/tests/unit/extensions/sphinx/QueryTest.php index cc52e74..cc3e27a 100644 --- a/tests/unit/extensions/sphinx/QueryTest.php +++ b/tests/unit/extensions/sphinx/QueryTest.php @@ -38,6 +38,10 @@ class QueryTest extends SphinxTestCase $match = 'test match'; $query->match($match); $this->assertEquals($match, $query->match); + + $command = $query->createCommand($this->getConnection(false)); + $this->assertContains('MATCH(', $command->getSql(), 'No MATCH operator present!'); + $this->assertContains($match, $command->params, 'No match query among params!'); } public function testWhere() From d6c388299d3af58060b39b8d4c0872b0f05e57b5 Mon Sep 17 00:00:00 2001 From: Klimov Paul Date: Thu, 14 Nov 2013 22:12:31 +0200 Subject: [PATCH 23/59] Sphinx ActiveRecord added as blank. --- extensions/sphinx/ActiveQuery.php | 207 ++++ extensions/sphinx/ActiveRecord.php | 1187 +++++++++++++++++++++ extensions/sphinx/Connection.php | 4 +- tests/unit/data/sphinx/ar/ActiveRecord.php | 16 + tests/unit/data/sphinx/ar/ArticleIndex.php | 18 + tests/unit/data/sphinx/ar/ItemIndex.php | 11 + tests/unit/data/sphinx/ar/RuntimeIndex.php | 11 + tests/unit/extensions/sphinx/ActiveRecordTest.php | 228 ++++ 8 files changed, 1680 insertions(+), 2 deletions(-) create mode 100644 extensions/sphinx/ActiveQuery.php create mode 100644 extensions/sphinx/ActiveRecord.php create mode 100644 tests/unit/data/sphinx/ar/ActiveRecord.php create mode 100644 tests/unit/data/sphinx/ar/ArticleIndex.php create mode 100644 tests/unit/data/sphinx/ar/ItemIndex.php create mode 100644 tests/unit/data/sphinx/ar/RuntimeIndex.php create mode 100644 tests/unit/extensions/sphinx/ActiveRecordTest.php diff --git a/extensions/sphinx/ActiveQuery.php b/extensions/sphinx/ActiveQuery.php new file mode 100644 index 0000000..26cc7ab --- /dev/null +++ b/extensions/sphinx/ActiveQuery.php @@ -0,0 +1,207 @@ + + * @since 2.0 + */ +class ActiveQuery extends Query +{ + /** + * @var string the name of the ActiveRecord class. + */ + public $modelClass; + /** + * @var array list of relations that this query should be performed with + */ + public $with; + /** + * @var boolean whether to return each record as an array. If false (default), an object + * of [[modelClass]] will be created to represent each record. + */ + public $asArray; + /** + * @var string the SQL statement to be executed for retrieving AR records. + * This is set by [[ActiveRecord::findBySql()]]. + */ + public $sql; + + /** + * PHP magic method. + * This method allows calling static method defined in [[modelClass]] via this query object. + * It is mainly implemented for supporting the feature of scope. + * @param string $name the method name to be called + * @param array $params the parameters passed to the method + * @return mixed the method return result + */ + public function __call($name, $params) + { + if (method_exists($this->modelClass, $name)) { + array_unshift($params, $this); + call_user_func_array([$this->modelClass, $name], $params); + return $this; + } else { + return parent::__call($name, $params); + } + } + + /** + * Executes query and returns all results as an array. + * @param Connection $db the DB connection used to create the DB command. + * If null, the DB connection returned by [[modelClass]] will be used. + * @return array the query results. If the query results in nothing, an empty array will be returned. + */ + public function all($db = null) + { + $command = $this->createCommand($db); + $rows = $command->queryAll(); + if (!empty($rows)) { + $models = $this->createModels($rows); + // TODO relations + /*if (!empty($this->with)) { + $this->populateRelations($models, $this->with); + }*/ + return $models; + } else { + return []; + } + } + + /** + * Executes query and returns a single row of result. + * @param Connection $db the DB connection used to create the DB command. + * If null, the DB connection returned by [[modelClass]] will be used. + * @return ActiveRecord|array|null a single row of query result. Depending on the setting of [[asArray]], + * the query result may be either an array or an ActiveRecord object. Null will be returned + * if the query results in nothing. + */ + public function one($db = null) + { + $command = $this->createCommand($db); + $row = $command->queryOne(); + if ($row !== false) { + if ($this->asArray) { + $model = $row; + } else { + /** @var $class ActiveRecord */ + $class = $this->modelClass; + $model = $class::create($row); + } + // TODO relations + /*if (!empty($this->with)) { + $models = [$model]; + $this->populateRelations($models, $this->with); + $model = $models[0]; + }*/ + return $model; + } else { + return null; + } + } + + /** + * Creates a DB command that can be used to execute this query. + * @param Connection $db the DB connection used to create the DB command. + * If null, the DB connection returned by [[modelClass]] will be used. + * @return Command the created DB command instance. + */ + public function createCommand($db = null) + { + /** @var $modelClass ActiveRecord */ + $modelClass = $this->modelClass; + if ($db === null) { + $db = $modelClass::getDb(); + } + + $params = $this->params; + if ($this->sql === null) { + if ($this->from === null) { + $tableName = $modelClass::indexName(); + if ($this->select === null && !empty($this->join)) { + $this->select = ["$tableName.*"]; + } + $this->from = [$tableName]; + } + list ($this->sql, $params) = $db->getQueryBuilder()->build($this); + } + return $db->createCommand($this->sql, $params); + } + + /** + * Sets the [[asArray]] property. + * @param boolean $value whether to return the query results in terms of arrays instead of Active Records. + * @return static the query object itself + */ + public function asArray($value = true) + { + $this->asArray = $value; + return $this; + } + + /** + * Sets the [[indexBy]] property. + * @param string|callable $column the name of the column by which the query results should be indexed by. + * This can also be a callable (e.g. anonymous function) that returns the index value based on the given + * row or model data. The signature of the callable should be: + * + * ~~~ + * // $model is an AR instance when `asArray` is false, + * // or an array of column values when `asArray` is true. + * function ($model) + * { + * // return the index value corresponding to $model + * } + * ~~~ + * + * @return static the query object itself + */ + public function indexBy($column) + { + return parent::indexBy($column); + } + + private function createModels($rows) + { + $models = []; + if ($this->asArray) { + 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) { + $models[] = $class::create($row); + } + } else { + foreach ($rows as $row) { + $model = $class::create($row); + if (is_string($this->indexBy)) { + $key = $model->{$this->indexBy}; + } else { + $key = call_user_func($this->indexBy, $model); + } + $models[$key] = $model; + } + } + } + return $models; + } +} \ No newline at end of file diff --git a/extensions/sphinx/ActiveRecord.php b/extensions/sphinx/ActiveRecord.php new file mode 100644 index 0000000..0f9122f --- /dev/null +++ b/extensions/sphinx/ActiveRecord.php @@ -0,0 +1,1187 @@ + + * @since 2.0 + */ +class ActiveRecord extends Model +{ + /** + * @event Event an event that is triggered when the record is initialized via [[init()]]. + */ + const EVENT_INIT = 'init'; + /** + * @event Event an event that is triggered after the record is created and populated with query result. + */ + const EVENT_AFTER_FIND = 'afterFind'; + /** + * @event ModelEvent an event that is triggered before inserting a record. + * You may set [[ModelEvent::isValid]] to be false to stop the insertion. + */ + const EVENT_BEFORE_INSERT = 'beforeInsert'; + /** + * @event Event an event that is triggered after a record is inserted. + */ + const EVENT_AFTER_INSERT = 'afterInsert'; + /** + * @event ModelEvent an event that is triggered before updating a record. + * You may set [[ModelEvent::isValid]] to be false to stop the update. + */ + const EVENT_BEFORE_UPDATE = 'beforeUpdate'; + /** + * @event Event an event that is triggered after a record is updated. + */ + const EVENT_AFTER_UPDATE = 'afterUpdate'; + /** + * @event ModelEvent an event that is triggered before deleting a record. + * You may set [[ModelEvent::isValid]] to be false to stop the deletion. + */ + const EVENT_BEFORE_DELETE = 'beforeDelete'; + /** + * @event Event an event that is triggered after a record is deleted. + */ + const EVENT_AFTER_DELETE = 'afterDelete'; + + /** + * The insert operation. This is mainly used when overriding [[transactions()]] to specify which operations are transactional. + */ + const OP_INSERT = 0x01; + /** + * The update operation. This is mainly used when overriding [[transactions()]] to specify which operations are transactional. + */ + const OP_UPDATE = 0x02; + /** + * The delete operation. This is mainly used when overriding [[transactions()]] to specify which operations are transactional. + */ + const OP_DELETE = 0x04; + /** + * All three operations: insert, update, delete. + * This is a shortcut of the expression: OP_INSERT | OP_UPDATE | OP_DELETE. + */ + const OP_ALL = 0x07; + + /** + * @var array attribute values indexed by attribute names + */ + private $_attributes = []; + /** + * @var array old attribute values indexed by attribute names. + */ + private $_oldAttributes; + /** + * @var array related models indexed by the relation names + */ + private $_related = []; + + /** + * Returns the Sphinx connection used by this AR class. + * By default, the "sphinx" application component is used as the Sphinx connection. + * You may override this method if you want to use a different Sphinx connection. + * @return Connection the Sphinx connection used by this AR class. + */ + public static function getDb() + { + return \Yii::$app->getComponent('sphinx'); + } + + /** + * Creates an [[ActiveQuery]] instance for query purpose. + * + * @param mixed $q the query parameter. This can be one of the followings: + * + * - a string: fulltext query by a query string and return the list + * of matching records. + * - an array of name-value pairs: query by a set of column values and return a single record matching all of them. + * - null: return a new [[ActiveQuery]] object for further query purpose. + * + * @return ActiveQuery|ActiveRecord[]|ActiveRecord|null When `$q` is null, a new [[ActiveQuery]] instance + * is returned; when `$q` is a string, an array of ActiveRecord objects matching it will be returned; + * when `$q` is an array, an ActiveRecord object matching it will be returned (null + * will be returned if there is no matching). + * @see createQuery() + */ + public static function find($q = null) + { + $query = static::createQuery(); + if (is_array($q)) { + return $query->where($q)->one(); + } elseif ($q !== null) { + return $query->match($q)->all(); + } + return $query; + } + + /** + * Creates an [[ActiveQuery]] instance with a given SQL statement. + * + * Note that because the SQL statement is already specified, calling additional + * query modification methods (such as `where()`, `order()`) on the created [[ActiveQuery]] + * instance will have no effect. However, calling `with()`, `asArray()` or `indexBy()` is + * still fine. + * + * Below is an example: + * + * ~~~ + * $customers = Customer::findBySql('SELECT * FROM tbl_customer')->all(); + * ~~~ + * + * @param string $sql the SQL statement to be executed + * @param array $params parameters to be bound to the SQL statement during execution. + * @return ActiveQuery the newly created [[ActiveQuery]] instance + */ + public static function findBySql($sql, $params = []) + { + $query = static::createQuery(); + $query->sql = $sql; + return $query->params($params); + } + + /** + * Updates the whole table 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 into the table + * @param string|array $condition the conditions that will be put in the WHERE part of the UPDATE SQL. + * Please refer to [[Query::where()]] on how to specify this parameter. + * @param array $params the parameters (name => value) to be bound to the query. + * @return integer the number of rows updated + */ + public static function updateAll($attributes, $condition = '', $params = []) + { + $command = static::getDb()->createCommand(); + $command->update(static::indexName(), $attributes, $condition, $params); + return $command->execute(); + } + + /** + * Updates the whole table using the provided counter changes and conditions. + * For example, to increment all customers' age by 1, + * + * ~~~ + * Customer::updateAllCounters(['age' => 1]); + * ~~~ + * + * @param array $counters the counters to be updated (attribute name => increment value). + * Use negative values if you want to decrement the counters. + * @param string|array $condition the conditions that will be put in the WHERE part of the UPDATE SQL. + * Please refer to [[Query::where()]] on how to specify this parameter. + * @param array $params the parameters (name => value) to be bound to the query. + * Do not name the parameters as `:bp0`, `:bp1`, etc., because they are used internally by this method. + * @return integer the number of rows updated + */ + public static function updateAllCounters($counters, $condition = '', $params = []) + { + $n = 0; + foreach ($counters as $name => $value) { + $counters[$name] = new Expression("[[$name]]+:bp{$n}", [":bp{$n}" => $value]); + $n++; + } + $command = static::getDb()->createCommand(); + $command->update(static::indexName(), $counters, $condition, $params); + return $command->execute(); + } + + /** + * Deletes rows in the table 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 string|array $condition the conditions that will be put in the WHERE part of the DELETE SQL. + * Please refer to [[Query::where()]] on how to specify this parameter. + * @param array $params the parameters (name => value) to be bound to the query. + * @return integer the number of rows deleted + */ + public static function deleteAll($condition = '', $params = []) + { + $command = static::getDb()->createCommand(); + $command->delete(static::indexName(), $condition, $params); + return $command->execute(); + } + + /** + * Creates an [[ActiveQuery]] instance. + * This method is called by [[find()]], [[findBySql()]] and [[count()]] to start a SELECT query. + * You may override this method to return a customized query (e.g. `CustomerQuery` specified + * written for querying `Customer` purpose.) + * @return ActiveQuery the newly created [[ActiveQuery]] instance. + */ + public static function createQuery() + { + return new ActiveQuery(['modelClass' => get_called_class()]); + } + + /** + * Declares the name of the database table associated with this AR class. + * By default this method returns the class name as the table name by calling [[Inflector::camel2id()]] + * with prefix 'tbl_'. For example, 'Customer' becomes 'tbl_customer', and 'OrderItem' becomes + * 'tbl_order_item'. You may override this method if the table is not named after this convention. + * @return string the table name + */ + public static function indexName() + { + return Inflector::camel2id(StringHelper::basename(get_called_class()), '_'); + } + + /** + * Returns the schema information of the DB table associated with this AR class. + * @return IndexSchema the schema information of the DB table associated with this AR class. + * @throws InvalidConfigException if the table for the AR class does not exist. + */ + public static function getIndexSchema() + { + $schema = static::getDb()->getIndexSchema(static::indexName()); + if ($schema !== null) { + return $schema; + } else { + throw new InvalidConfigException("The index does not exist: " . static::indexName()); + } + } + + /** + * Returns the primary key name for this AR class. + * @return string the primary keys of the associated database table. + */ + public static function primaryKey() + { + return static::getIndexSchema()->primaryKey; + } + + /** + * Returns the name of the column that stores the lock version for implementing optimistic locking. + * + * Optimistic locking allows multiple users to access the same record for edits and avoids + * potential conflicts. In case when a user attempts to save the record upon some staled data + * (because another user has modified the data), a [[StaleObjectException]] exception will be thrown, + * and the update or deletion is skipped. + * + * Optimized locking is only supported by [[update()]] and [[delete()]]. + * + * To use optimized locking: + * + * 1. Create a column to store the version number of each row. The column type should be `BIGINT DEFAULT 0`. + * Override this method to return the name of this column. + * 2. In the Web form that collects the user input, add a hidden field that stores + * the lock version of the recording being updated. + * 3. In the controller action that does the data updating, try to catch the [[StaleObjectException]] + * and implement necessary business logic (e.g. merging the changes, prompting stated data) + * to resolve the conflict. + * + * @return string the column name that stores the lock version of a table row. + * If null is returned (default implemented), optimistic locking will not be supported. + */ + public function optimisticLock() + { + return null; + } + + /** + * Declares which DB operations should be performed within a transaction in different scenarios. + * The supported DB operations are: [[OP_INSERT]], [[OP_UPDATE]] and [[OP_DELETE]], + * which correspond to the [[insert()]], [[update()]] and [[delete()]] methods, respectively. + * By default, these methods are NOT enclosed in a DB transaction. + * + * In some scenarios, to ensure data consistency, you may want to enclose some or all of them + * in transactions. You can do so by overriding this method and returning the operations + * that need to be transactional. For example, + * + * ~~~ + * return [ + * 'admin' => self::OP_INSERT, + * 'api' => self::OP_INSERT | self::OP_UPDATE | self::OP_DELETE, + * // the above is equivalent to the following: + * // 'api' => self::OP_ALL, + * + * ]; + * ~~~ + * + * The above declaration specifies that in the "admin" scenario, the insert operation ([[insert()]]) + * should be done in a transaction; and in the "api" scenario, all the operations should be done + * in a transaction. + * + * @return array the declarations of transactional operations. The array keys are scenarios names, + * and the array values are the corresponding transaction operations. + */ + public function transactions() + { + return []; + } + + /** + * PHP getter magic method. + * This method is overridden so that attributes and related objects can be accessed like properties. + * @param string $name property name + * @return mixed property value + * @see getAttribute + */ + public function __get($name) + { + if (isset($this->_attributes[$name]) || array_key_exists($name, $this->_attributes)) { + return $this->_attributes[$name]; + } elseif ($this->hasAttribute($name)) { + return null; + } else { + if (isset($this->_related[$name]) || array_key_exists($name, $this->_related)) { + return $this->_related[$name]; + } + $value = parent::__get($name); + // TODO: relation + if ($value instanceof ActiveRelation) { + return $this->_related[$name] = $value->multiple ? $value->all() : $value->one(); + } else { + return $value; + } + } + } + + /** + * PHP setter magic method. + * This method is overridden so that AR attributes can be accessed like properties. + * @param string $name property name + * @param mixed $value property value + */ + public function __set($name, $value) + { + if ($this->hasAttribute($name)) { + $this->_attributes[$name] = $value; + } else { + parent::__set($name, $value); + } + } + + /** + * Checks if a property value is null. + * This method overrides the parent implementation by checking if the named attribute is null or not. + * @param string $name the property name or the event name + * @return boolean whether the property value is null + */ + public function __isset($name) + { + try { + return $this->__get($name) !== null; + } catch (\Exception $e) { + return false; + } + } + + /** + * Sets a component property to be null. + * This method overrides the parent implementation by clearing + * the specified attribute value. + * @param string $name the property name or the event name + */ + public function __unset($name) + { + if ($this->hasAttribute($name)) { + unset($this->_attributes[$name]); + } else { + if (isset($this->_related[$name])) { + unset($this->_related[$name]); + } else { + parent::__unset($name); + } + } + } + + /** + * Populates the named relation with the related records. + * Note that this method does not check if the relation exists or not. + * @param string $name the relation name (case-sensitive) + * @param ActiveRecord|array|null the related records to be populated into the relation. + */ + public function populateRelation($name, $records) + { + $this->_related[$name] = $records; + } + + /** + * Check whether the named relation has been populated with records. + * @param string $name the relation name (case-sensitive) + * @return bool whether relation has been populated with records. + */ + public function isRelationPopulated($name) + { + return array_key_exists($name, $this->_related); + } + + /** + * Returns all populated relations. + * @return array an array of relation data indexed by relation names. + */ + public function getPopulatedRelations() + { + return $this->_related; + } + + /** + * Returns the list of all attribute names of the model. + * The default implementation will return all column names of the table associated with this AR class. + * @return array list of attribute names. + */ + public function attributes() + { + return array_keys($this->getIndexSchema()->columns); + } + + /** + * Returns a value indicating whether the model has an attribute with the specified name. + * @param string $name the name of the attribute + * @return boolean whether the model has an attribute with the specified name. + */ + public function hasAttribute($name) + { + return isset($this->_attributes[$name]) || isset($this->getIndexSchema()->columns[$name]); + } + + /** + * Returns the named attribute value. + * If this record is the result of a query and the attribute is not loaded, + * null will be returned. + * @param string $name the attribute name + * @return mixed the attribute value. Null if the attribute is not set or does not exist. + * @see hasAttribute + */ + public function getAttribute($name) + { + return isset($this->_attributes[$name]) ? $this->_attributes[$name] : null; + } + + /** + * Sets the named attribute value. + * @param string $name the attribute name + * @param mixed $value the attribute value. + * @throws InvalidParamException if the named attribute does not exist. + * @see hasAttribute + */ + public function setAttribute($name, $value) + { + if ($this->hasAttribute($name)) { + $this->_attributes[$name] = $value; + } else { + throw new InvalidParamException(get_class($this) . ' has no attribute named "' . $name . '".'); + } + } + + /** + * Returns the old attribute values. + * @return array the old attribute values (name-value pairs) + */ + public function getOldAttributes() + { + return $this->_oldAttributes === null ? [] : $this->_oldAttributes; + } + + /** + * Sets the old attribute values. + * All existing old attribute values will be discarded. + * @param array $values old attribute values to be set. + */ + public function setOldAttributes($values) + { + $this->_oldAttributes = $values; + } + + /** + * Returns the old value of the named attribute. + * If this record is the result of a query and the attribute is not loaded, + * null will be returned. + * @param string $name the attribute name + * @return mixed the old attribute value. Null if the attribute is not loaded before + * or does not exist. + * @see hasAttribute + */ + public function getOldAttribute($name) + { + return isset($this->_oldAttributes[$name]) ? $this->_oldAttributes[$name] : null; + } + + /** + * Sets the old value of the named attribute. + * @param string $name the attribute name + * @param mixed $value the old attribute value. + * @throws InvalidParamException if the named attribute does not exist. + * @see hasAttribute + */ + public function setOldAttribute($name, $value) + { + if (isset($this->_oldAttributes[$name]) || $this->hasAttribute($name)) { + $this->_oldAttributes[$name] = $value; + } else { + throw new InvalidParamException(get_class($this) . ' has no attribute named "' . $name . '".'); + } + } + + /** + * Returns a value indicating whether the named attribute has been changed. + * @param string $name the name of the attribute + * @return boolean whether the attribute has been changed + */ + public function isAttributeChanged($name) + { + if (isset($this->_attributes[$name], $this->_oldAttributes[$name])) { + return $this->_attributes[$name] !== $this->_oldAttributes[$name]; + } else { + return isset($this->_attributes[$name]) || isset($this->_oldAttributes[$name]); + } + } + + /** + * Returns the attribute values that have been modified since they are loaded or saved most recently. + * @param string[]|null $names the names of the attributes whose values may be returned if they are + * changed recently. If null, [[attributes()]] will be used. + * @return array the changed attribute values (name-value pairs) + */ + public function getDirtyAttributes($names = null) + { + if ($names === null) { + $names = $this->attributes(); + } + $names = array_flip($names); + $attributes = []; + if ($this->_oldAttributes === null) { + foreach ($this->_attributes as $name => $value) { + if (isset($names[$name])) { + $attributes[$name] = $value; + } + } + } else { + foreach ($this->_attributes as $name => $value) { + if (isset($names[$name]) && (!array_key_exists($name, $this->_oldAttributes) || $value !== $this->_oldAttributes[$name])) { + $attributes[$name] = $value; + } + } + } + return $attributes; + } + + /** + * Saves the current record. + * + * This method will call [[insert()]] when [[isNewRecord]] is true, or [[update()]] + * when [[isNewRecord]] is false. + * + * For example, to save a customer record: + * + * ~~~ + * $customer = new Customer; // or $customer = Customer::find($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. + * @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 saving succeeds + */ + public function save($runValidation = true, $attributes = null) + { + if ($this->getIsNewRecord()) { + return $this->insert($runValidation, $attributes); + } else { + return $this->update($runValidation, $attributes) !== false; + } + } + + /** + * Inserts a row into the associated database table using the attribute values of this record. + * + * This method performs the following steps in order: + * + * 1. call [[beforeValidate()]] when `$runValidation` is true. If validation + * fails, it will skip the rest of the steps; + * 2. call [[afterValidate()]] when `$runValidation` is true. + * 3. call [[beforeSave()]]. If the method returns false, it will skip the + * rest of the steps; + * 4. insert the record into database. If this fails, it will skip the rest of the steps; + * 5. call [[afterSave()]]; + * + * In the above step 1, 2, 3 and 5, events [[EVENT_BEFORE_VALIDATE]], + * [[EVENT_BEFORE_INSERT]], [[EVENT_AFTER_INSERT]] and [[EVENT_AFTER_VALIDATE]] + * will be raised by the corresponding methods. + * + * Only the [[changedAttributes|changed attribute values]] will be inserted into database. + * + * If the table's primary key is auto-incremental and is null during insertion, + * it will be populated with the actual value after insertion. + * + * For example, to insert a customer record: + * + * ~~~ + * $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. + * @throws \Exception in case insert failed. + */ + public function insert($runValidation = true, $attributes = null) + { + if ($runValidation && !$this->validate($attributes)) { + return false; + } + $db = static::getDb(); + if ($this->isTransactional(self::OP_INSERT) && $db->getTransaction() === null) { + $transaction = $db->beginTransaction(); + try { + $result = $this->insertInternal($attributes); + if ($result === false) { + $transaction->rollback(); + } else { + $transaction->commit(); + } + } catch (\Exception $e) { + $transaction->rollback(); + throw $e; + } + } else { + $result = $this->insertInternal($attributes); + } + return $result; + } + + /** + * @see ActiveRecord::insert() + */ + private function insertInternal($attributes = null) + { + if (!$this->beforeSave(true)) { + return false; + } + $values = $this->getDirtyAttributes($attributes); + if (empty($values)) { + $key = $this->primaryKey(); + $values[$key] = isset($this->_attributes[$key]) ? $this->_attributes[$key] : null; + } + $db = static::getDb(); + $command = $db->createCommand()->insert($this->indexName(), $values); + if (!$command->execute()) { + return false; + } + foreach ($values as $name => $value) { + $this->_oldAttributes[$name] = $value; + } + $this->afterSave(true); + return true; + } + + /** + * Saves the changes to this active record into the associated database table. + * + * This method performs the following steps in order: + * + * 1. call [[beforeValidate()]] when `$runValidation` is true. If validation + * fails, it will skip the rest of the steps; + * 2. call [[afterValidate()]] when `$runValidation` is true. + * 3. call [[beforeSave()]]. If the method returns false, it will skip the + * rest of the steps; + * 4. save the record into database. If this fails, it will skip the rest of the steps; + * 5. call [[afterSave()]]; + * + * In the above step 1, 2, 3 and 5, events [[EVENT_BEFORE_VALIDATE]], + * [[EVENT_BEFORE_UPDATE]], [[EVENT_AFTER_UPDATE]] and [[EVENT_AFTER_VALIDATE]] + * will be raised by the corresponding methods. + * + * Only the [[changedAttributes|changed attribute values]] will be saved into database. + * + * For example, to update a customer record: + * + * ~~~ + * $customer = Customer::find($id); + * $customer->name = $name; + * $customer->email = $email; + * $customer->update(); + * ~~~ + * + * Note that it is possible the update does not affect any row in the table. + * In this case, this method will return 0. For this reason, you should use the following + * code to check if update() is successful or not: + * + * ~~~ + * if ($this->update() !== false) { + * // update successful + * } else { + * // update failed + * } + * ~~~ + * + * @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 integer|boolean the number of rows affected, or false if validation fails + * or [[beforeSave()]] stops the updating process. + * @throws StaleObjectException if [[optimisticLock|optimistic locking]] is enabled and the data + * being updated is outdated. + * @throws \Exception in case update failed. + */ + public function update($runValidation = true, $attributes = null) + { + if ($runValidation && !$this->validate($attributes)) { + return false; + } + $db = static::getDb(); + if ($this->isTransactional(self::OP_UPDATE) && $db->getTransaction() === null) { + $transaction = $db->beginTransaction(); + try { + $result = $this->updateInternal($attributes); + if ($result === false) { + $transaction->rollback(); + } else { + $transaction->commit(); + } + } catch (\Exception $e) { + $transaction->rollback(); + throw $e; + } + } else { + $result = $this->updateInternal($attributes); + } + return $result; + } + + /** + * @see CActiveRecord::update() + * @throws StaleObjectException + */ + private function updateInternal($attributes = null) + { + if (!$this->beforeSave(false)) { + return false; + } + $values = $this->getDirtyAttributes($attributes); + if (empty($values)) { + $this->afterSave(false); + 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; + } + // We do not check the return value of updateAll() because it's possible + // that the UPDATE statement doesn't change anything and thus returns 0. + $rows = $this->updateAll($values, $condition); + + if ($lock !== null && !$rows) { + throw new StaleObjectException('The object being updated is outdated.'); + } + + foreach ($values as $name => $value) { + $this->_oldAttributes[$name] = $this->_attributes[$name]; + } + $this->afterSave(false); + return $rows; + } + + /** + * Updates one or several counter columns for the current AR object. + * Note that this method differs from [[updateAllCounters()]] in that it only + * saves counters for the current AR object. + * + * An example usage is as follows: + * + * ~~~ + * $post = Post::find($id); + * $post->updateCounters(['view_count' => 1]); + * ~~~ + * + * @param array $counters the counters to be updated (attribute name => increment value) + * Use negative values if you want to decrement the counters. + * @return boolean whether the saving is successful + * @see updateAllCounters() + */ + public function updateCounters($counters) + { + if ($this->updateAllCounters($counters, $this->getOldPrimaryKey(true)) > 0) { + foreach ($counters as $name => $value) { + $this->_attributes[$name] += $value; + $this->_oldAttributes[$name] = $this->_attributes[$name]; + } + return true; + } else { + return false; + } + } + + /** + * Deletes the table row corresponding to this active record. + * + * This method performs the following steps in order: + * + * 1. call [[beforeDelete()]]. If the method returns false, it will skip the + * rest of the steps; + * 2. delete the record from the database; + * 3. call [[afterDelete()]]. + * + * In the above step 1 and 3, events named [[EVENT_BEFORE_DELETE]] and [[EVENT_AFTER_DELETE]] + * will be raised by the corresponding methods. + * + * @return integer|boolean the number of rows deleted, or false if the deletion is unsuccessful for some reason. + * Note that it is possible the number of rows deleted is 0, even though the deletion execution is successful. + * @throws StaleObjectException if [[optimisticLock|optimistic locking]] is enabled and the data + * being deleted is outdated. + * @throws \Exception in case delete failed. + */ + public function delete() + { + $db = static::getDb(); + $transaction = $this->isTransactional(self::OP_DELETE) && $db->getTransaction() === null ? $db->beginTransaction() : null; + try { + $result = false; + if ($this->beforeDelete()) { + // we do not check the return value of deleteAll() because it's possible + // the record is already deleted in the database and thus the method will return 0 + $condition = $this->getOldPrimaryKey(true); + $lock = $this->optimisticLock(); + if ($lock !== null) { + $condition[$lock] = $this->$lock; + } + $result = $this->deleteAll($condition); + if ($lock !== null && !$result) { + throw new StaleObjectException('The object being deleted is outdated.'); + } + $this->_oldAttributes = null; + $this->afterDelete(); + } + if ($transaction !== null) { + if ($result === false) { + $transaction->rollback(); + } else { + $transaction->commit(); + } + } + } catch (\Exception $e) { + if ($transaction !== null) { + $transaction->rollback(); + } + throw $e; + } + return $result; + } + + /** + * Returns a value indicating whether the current record is new. + * @return boolean whether the record is new and should be inserted when calling [[save()]]. + */ + public function getIsNewRecord() + { + return $this->_oldAttributes === null; + } + + /** + * Sets the value indicating whether the record is new. + * @param boolean $value whether the record is new and should be inserted when calling [[save()]]. + * @see getIsNewRecord + */ + public function setIsNewRecord($value) + { + $this->_oldAttributes = $value ? null : $this->_attributes; + } + + /** + * Initializes the object. + * This method is called at the end of the constructor. + * The default implementation will trigger an [[EVENT_INIT]] event. + * If you override this method, make sure you call the parent implementation at the end + * to ensure triggering of the event. + */ + public function init() + { + parent::init(); + $this->trigger(self::EVENT_INIT); + } + + /** + * This method is called when the AR object is created and populated with the query result. + * The default implementation will trigger an [[EVENT_AFTER_FIND]] event. + * When overriding this method, make sure you call the parent implementation to ensure the + * event is triggered. + */ + public function afterFind() + { + $this->trigger(self::EVENT_AFTER_FIND); + } + + /** + * This method is called at the beginning of inserting or updating a record. + * The default implementation will trigger an [[EVENT_BEFORE_INSERT]] event when `$insert` is true, + * or an [[EVENT_BEFORE_UPDATE]] event if `$insert` is false. + * When overriding this method, make sure you call the parent implementation like the following: + * + * ~~~ + * public function beforeSave($insert) + * { + * if (parent::beforeSave($insert)) { + * // ...custom code here... + * return true; + * } else { + * return false; + * } + * } + * ~~~ + * + * @param boolean $insert whether this method called while inserting a record. + * If false, it means the method is called while updating a record. + * @return boolean whether the insertion or updating should continue. + * If false, the insertion or updating will be cancelled. + */ + public function beforeSave($insert) + { + $event = new ModelEvent; + $this->trigger($insert ? self::EVENT_BEFORE_INSERT : self::EVENT_BEFORE_UPDATE, $event); + return $event->isValid; + } + + /** + * This method is called at the end of inserting or updating a record. + * The default implementation will trigger an [[EVENT_AFTER_INSERT]] event when `$insert` is true, + * or an [[EVENT_AFTER_UPDATE]] event if `$insert` is false. + * When overriding this method, make sure you call the parent implementation so that + * the event is triggered. + * @param boolean $insert whether this method called while inserting a record. + * If false, it means the method is called while updating a record. + */ + public function afterSave($insert) + { + $this->trigger($insert ? self::EVENT_AFTER_INSERT : self::EVENT_AFTER_UPDATE); + } + + /** + * This method is invoked before deleting a record. + * The default implementation raises the [[EVENT_BEFORE_DELETE]] event. + * When overriding this method, make sure you call the parent implementation like the following: + * + * ~~~ + * public function beforeDelete() + * { + * if (parent::beforeDelete()) { + * // ...custom code here... + * return true; + * } else { + * return false; + * } + * } + * ~~~ + * + * @return boolean whether the record should be deleted. Defaults to true. + */ + public function beforeDelete() + { + $event = new ModelEvent; + $this->trigger(self::EVENT_BEFORE_DELETE, $event); + return $event->isValid; + } + + /** + * This method is invoked after deleting a record. + * The default implementation raises the [[EVENT_AFTER_DELETE]] event. + * You may override this method to do postprocessing after the record is deleted. + * Make sure you call the parent implementation so that the event is raised properly. + */ + public function afterDelete() + { + $this->trigger(self::EVENT_AFTER_DELETE); + } + + /** + * Repopulates this active record with the latest data. + * @return boolean whether the row still exists in the database. If true, the latest data + * will be populated to this active record. Otherwise, this record will remain unchanged. + */ + public function refresh() + { + $record = $this->find($this->getPrimaryKey(true)); + if ($record === null) { + return false; + } + foreach ($this->attributes() as $name) { + $this->_attributes[$name] = $record->_attributes[$name]; + } + $this->_oldAttributes = $this->_attributes; + $this->_related = []; + return true; + } + + /** + * Returns a value indicating whether the given active record is the same as the current one. + * The comparison is made by comparing the index names and the primary key values of the two active records. + * @param ActiveRecord $record record to compare to + * @return boolean whether the two active records refer to the same row in the same index. + */ + public function equals($record) + { + return $this->indexName() === $record->indexName() && $this->getPrimaryKey() === $record->getPrimaryKey(); + } + + /** + * Returns the primary key value. + * @param boolean $asArray whether to return the primary key value as an array. If true, + * the return value will be an array with column names as keys and column values as values. + * @return mixed the primary key value. An array (column name => column value) is returned + * if `$asArray` is true. A string is returned otherwise (null will be returned if + * the key value is null). + */ + public function getPrimaryKey($asArray = false) + { + $key = $this->primaryKey(); + $value = isset($this->_attributes[$key]) ? $this->_attributes[$key] : null; + if ($asArray) { + return [$key => $value]; + } else { + return $value; + } + } + + /** + * Returns the old primary key value. + * This refers to the primary key value that is populated into the record + * after executing a find method (e.g. find(), findAll()). + * The value remains unchanged even if the primary key attribute is manually assigned with a different value. + * @param boolean $asArray whether to return the primary key value as an array. If true, + * the return value will be an array with column name as key and column value as value. + * If this is false (default), a scalar value will be returned. + * @return mixed the old primary key value. An array (column name => column value) is returned if + * `$asArray` is true. A string is returned otherwise (null will be returned if + * the key value is null). + */ + public function getOldPrimaryKey($asArray = false) + { + $key = $this->primaryKey(); + $value = isset($this->_oldAttributes[$key]) ? $this->_oldAttributes[$key] : null; + if ($asArray) { + return [$key => $value]; + } else { + return $value; + } + } + + /** + * Creates an active record object using a row of data. + * This method is called by [[ActiveQuery]] to populate the query results + * into Active Records. It is not meant to be used to create new records. + * @param array $row attribute values (name => value) + * @return ActiveRecord the newly created active record. + */ + public static function create($row) + { + $record = static::instantiate($row); + $columns = static::getIndexSchema()->columns; + foreach ($row as $name => $value) { + if (isset($columns[$name])) { + $column = $columns[$name]; + if ($column->isMva) { + $value = explode(',', $value); + $value = array_map([$column, 'typecast'], $value); + } else { + $value = $column->typecast($value); + } + $record->_attributes[$name] = $value; + } else { + $record->$name = $value; + } + } + $record->_oldAttributes = $record->_attributes; + $record->afterFind(); + return $record; + } + + /** + * Creates an active record instance. + * This method is called by [[create()]]. + * You may override this method if the instance being created + * depends on the row data to be populated into the record. + * For example, by creating a record based on the value of a column, + * you may implement the so-called single-table inheritance mapping. + * @param array $row row data to be populated into the record. + * @return ActiveRecord the newly created active record + */ + public static function instantiate($row) + { + return new static; + } + + /** + * Returns whether there is an element at the specified offset. + * This method is required by the interface ArrayAccess. + * @param mixed $offset the offset to check on + * @return boolean whether there is an element at the specified offset. + */ + public function offsetExists($offset) + { + return $this->__isset($offset); + } + + /** + * Returns the relation object with the specified name. + * A relation is defined by a getter method which returns an [[ActiveRelation]] object. + * It can be declared in either the Active Record class itself or one of its behaviors. + * @param string $name the relation name + * @return ActiveRelation the relation object + * @throws InvalidParamException if the named relation does not exist. + */ + public function getRelation($name) + { + $getter = 'get' . $name; + try { + $relation = $this->$getter(); + if ($relation instanceof ActiveRelation) { + return $relation; + } else { + return null; + } + } catch (UnknownMethodException $e) { + throw new InvalidParamException(get_class($this) . ' has no relation named "' . $name . '".', 0, $e); + } + } + + /** + * Returns a value indicating whether the specified operation is transactional in the current [[scenario]]. + * @param integer $operation the operation to check. Possible values are [[OP_INSERT]], [[OP_UPDATE]] and [[OP_DELETE]]. + * @return boolean whether the specified operation is transactional in the current [[scenario]]. + */ + public function isTransactional($operation) + { + $scenario = $this->getScenario(); + $transactions = $this->transactions(); + return isset($transactions[$scenario]) && ($transactions[$scenario] & $operation); + } +} \ No newline at end of file diff --git a/extensions/sphinx/Connection.php b/extensions/sphinx/Connection.php index 6009f1f..3bb6d63 100644 --- a/extensions/sphinx/Connection.php +++ b/extensions/sphinx/Connection.php @@ -11,10 +11,10 @@ namespace yii\sphinx; * Class Connection * * @property Schema $schema The schema information for this Sphinx connection. This property is read-only. - * @property QueryBuilder $queryBuilder The query builder for this Sphinx connection. This property is + * @property \yii\sphinx\QueryBuilder $queryBuilder The query builder for this Sphinx connection. This property is * read-only. * @method Schema getSchema() The schema information for this Sphinx connection - * @method QueryBuilder getQueryBuilder() the query builder for this Sphinx connection + * @method \yii\sphinx\QueryBuilder getQueryBuilder() the query builder for this Sphinx connection * * @author Paul Klimov * @since 2.0 diff --git a/tests/unit/data/sphinx/ar/ActiveRecord.php b/tests/unit/data/sphinx/ar/ActiveRecord.php new file mode 100644 index 0000000..12150b2 --- /dev/null +++ b/tests/unit/data/sphinx/ar/ActiveRecord.php @@ -0,0 +1,16 @@ +andWhere('author_id=1'); + } +} \ No newline at end of file diff --git a/tests/unit/data/sphinx/ar/ItemIndex.php b/tests/unit/data/sphinx/ar/ItemIndex.php new file mode 100644 index 0000000..2322429 --- /dev/null +++ b/tests/unit/data/sphinx/ar/ItemIndex.php @@ -0,0 +1,11 @@ +getConnection(); + } + + protected function tearDown() + { + $this->truncateRuntimeIndex('yii2_test_rt_index'); + parent::tearDown(); + } + + // Tests : + + public function testFind() + { + // find one + $result = ArticleIndex::find(); + $this->assertTrue($result instanceof ActiveQuery); + $article = $result->one(); + $this->assertTrue($article instanceof ArticleIndex); + + // find all + $articles = ArticleIndex::find()->all(); + $this->assertEquals(2, count($articles)); + $this->assertTrue($articles[0] instanceof ArticleIndex); + $this->assertTrue($articles[1] instanceof ArticleIndex); + + // find fulltext + $articles = ArticleIndex::find('cats'); + $this->assertEquals(1, count($articles)); + $this->assertTrue($articles[0] instanceof ArticleIndex); + $this->assertEquals(1, $articles[0]->id); + + // find by column values + $article = ArticleIndex::find(['id' => 2, 'author_id' => 2]); + $this->assertTrue($article instanceof ArticleIndex); + $this->assertEquals(2, $article->id); + $this->assertEquals(2, $article->author_id); + $article = ArticleIndex::find(['id' => 2, 'author_id' => 1]); + $this->assertNull($article); + + // find by attributes + $article = ArticleIndex::find()->where(['author_id' => 2])->one(); + $this->assertTrue($article instanceof ArticleIndex); + $this->assertEquals(2, $article->id); + + // find custom column + $article = ArticleIndex::find()->select(['*', '(5*2) AS custom_column']) + ->where(['author_id' => 1])->one(); + $this->assertEquals(1, $article->id); + $this->assertEquals(10, $article->custom_column); + + // find count, sum, average, min, max, scalar + $this->assertEquals(2, ArticleIndex::find()->count()); + $this->assertEquals(1, ArticleIndex::find()->where('id=1')->count()); + $this->assertEquals(3, ArticleIndex::find()->sum('id')); + $this->assertEquals(1.5, ArticleIndex::find()->average('id')); + $this->assertEquals(1, ArticleIndex::find()->min('id')); + $this->assertEquals(2, ArticleIndex::find()->max('id')); + $this->assertEquals(2, ArticleIndex::find()->select('COUNT(*)')->scalar()); + + // scope + $this->assertEquals(1, ArticleIndex::find()->favoriteAuthor()->count()); + + // asArray + $article = ArticleIndex::find()->where('id=2')->asArray()->one(); + $this->assertEquals([ + 'id' => '2', + 'author_id' => '2', + 'add_date' => '1384466400', + 'tag' => '3,4', + ], $article); + + // indexBy + $articles = ArticleIndex::find()->indexBy('author_id')->orderBy('id DESC')->all(); + $this->assertEquals(2, count($articles)); + $this->assertTrue($articles['1'] instanceof ArticleIndex); + $this->assertTrue($articles['2'] instanceof ArticleIndex); + + // indexBy callable + $articles = ArticleIndex::find()->indexBy(function ($article) { + return $article->id . '-' . $article->author_id; + })->orderBy('id DESC')->all(); + $this->assertEquals(2, count($articles)); + $this->assertTrue($articles['1-1'] instanceof ArticleIndex); + $this->assertTrue($articles['2-2'] instanceof ArticleIndex); + } + + public function testFindBySql() + { + // find one + $article = ArticleIndex::findBySql('SELECT * FROM yii2_test_article_index ORDER BY id DESC')->one(); + $this->assertTrue($article instanceof ArticleIndex); + $this->assertEquals(2, $article->author_id); + + // find all + $articles = ArticleIndex::findBySql('SELECT * FROM yii2_test_article_index')->all(); + $this->assertEquals(2, count($articles)); + + // find with parameter binding + $article = ArticleIndex::findBySql('SELECT * FROM yii2_test_article_index WHERE id=:id', [':id' => 2])->one(); + $this->assertTrue($article instanceof ArticleIndex); + $this->assertEquals(2, $article->author_id); + } + + public function testInsert() + { + $record = new RuntimeIndex; + $record->id = 15; + $record->title = 'test title'; + $record->content = 'test content'; + $record->type_id = 7; + $record->category = [1, 2]; + + $this->assertTrue($record->isNewRecord); + + $record->save(); + + $this->assertEquals(15, $record->id); + $this->assertFalse($record->isNewRecord); + } + + /** + * @depends testInsert + */ + public function testUpdate() + { + $record = new RuntimeIndex; + $record->id = 2; + $record->title = 'test title'; + $record->content = 'test content'; + $record->type_id = 7; + $record->category = [1, 2]; + $record->save(); + + // save + $record = RuntimeIndex::find(['id' => 2]); + $this->assertTrue($record instanceof RuntimeIndex); + $this->assertEquals(7, $record->type_id); + $this->assertFalse($record->isNewRecord); + + $record->type_id = 9; + $record->save(); + $this->assertEquals(9, $record->type_id); + $this->assertFalse($record->isNewRecord); + $record2 = RuntimeIndex::find(['id' => 2]); + $this->assertEquals(9, $record2->type_id); + + // updateCounters + /*$pk = ['id' => 1]; + $record = RuntimeIndex::find($pk); + $this->assertEquals(1, $record->quantity); + $ret = $record->updateCounters(['quantity' => -1]); + $this->assertTrue($ret); + $this->assertEquals(0, $record->quantity); + $record = RuntimeIndex::find($pk); + $this->assertEquals(0, $record->quantity);*/ + + // updateAll + $pk = ['id' => 2]; + $ret = RuntimeIndex::updateAll(['type_id' => 55], $pk); + $this->assertEquals(1, $ret); + $record = RuntimeIndex::find($pk); + $this->assertEquals(55, $record->type_id); + + // updateAllCounters + /*$pk = ['order_id' => 1, 'item_id' => 2]; + $record = RuntimeIndex::find($pk); + $this->assertEquals(2, $record->quantity); + $ret = RuntimeIndex::updateAllCounters([ + 'quantity' => 3, + 'subtotal' => -10, + ], $pk); + $this->assertEquals(1, $ret); + $record = RuntimeIndex::find($pk); + $this->assertEquals(5, $record->quantity); + $this->assertEquals(30, $record->subtotal);*/ + } + + /** + * @depends testInsert + */ + public function testDelete() + { + // delete + $record = new RuntimeIndex; + $record->id = 2; + $record->title = 'test title'; + $record->content = 'test content'; + $record->type_id = 7; + $record->category = [1, 2]; + $record->save(); + + $record = RuntimeIndex::find(['id' => 2]); + $record->delete(); + $record = RuntimeIndex::find(['id' => 2]); + $this->assertNull($record); + + // deleteAll + $record = new RuntimeIndex; + $record->id = 2; + $record->title = 'test title'; + $record->content = 'test content'; + $record->type_id = 7; + $record->category = [1, 2]; + $record->save(); + + $ret = RuntimeIndex::deleteAll('id = 2'); + $this->assertEquals(1, $ret); + $records = RuntimeIndex::find()->all(); + $this->assertEquals(0, count($records)); + } +} \ No newline at end of file From 37862b6ee228c0af093fa01e0be89aeff0787c5f Mon Sep 17 00:00:00 2001 From: Paul Klimov Date: Fri, 15 Nov 2013 11:30:24 +0200 Subject: [PATCH 24/59] Unsupported "update counters" feature removed from Sphinx Active Record. --- extensions/sphinx/ActiveRecord.php | 58 ----------------------- tests/unit/extensions/sphinx/ActiveRecordTest.php | 23 --------- 2 files changed, 81 deletions(-) diff --git a/extensions/sphinx/ActiveRecord.php b/extensions/sphinx/ActiveRecord.php index 0f9122f..f821200 100644 --- a/extensions/sphinx/ActiveRecord.php +++ b/extensions/sphinx/ActiveRecord.php @@ -177,34 +177,6 @@ class ActiveRecord extends Model } /** - * Updates the whole table using the provided counter changes and conditions. - * For example, to increment all customers' age by 1, - * - * ~~~ - * Customer::updateAllCounters(['age' => 1]); - * ~~~ - * - * @param array $counters the counters to be updated (attribute name => increment value). - * Use negative values if you want to decrement the counters. - * @param string|array $condition the conditions that will be put in the WHERE part of the UPDATE SQL. - * Please refer to [[Query::where()]] on how to specify this parameter. - * @param array $params the parameters (name => value) to be bound to the query. - * Do not name the parameters as `:bp0`, `:bp1`, etc., because they are used internally by this method. - * @return integer the number of rows updated - */ - public static function updateAllCounters($counters, $condition = '', $params = []) - { - $n = 0; - foreach ($counters as $name => $value) { - $counters[$name] = new Expression("[[$name]]+:bp{$n}", [":bp{$n}" => $value]); - $n++; - } - $command = static::getDb()->createCommand(); - $command->update(static::indexName(), $counters, $condition, $params); - return $command->execute(); - } - - /** * Deletes rows in the table using the provided conditions. * WARNING: If you do not specify any condition, this method will delete ALL rows in the table. * @@ -813,36 +785,6 @@ class ActiveRecord extends Model } /** - * Updates one or several counter columns for the current AR object. - * Note that this method differs from [[updateAllCounters()]] in that it only - * saves counters for the current AR object. - * - * An example usage is as follows: - * - * ~~~ - * $post = Post::find($id); - * $post->updateCounters(['view_count' => 1]); - * ~~~ - * - * @param array $counters the counters to be updated (attribute name => increment value) - * Use negative values if you want to decrement the counters. - * @return boolean whether the saving is successful - * @see updateAllCounters() - */ - public function updateCounters($counters) - { - if ($this->updateAllCounters($counters, $this->getOldPrimaryKey(true)) > 0) { - foreach ($counters as $name => $value) { - $this->_attributes[$name] += $value; - $this->_oldAttributes[$name] = $this->_attributes[$name]; - } - return true; - } else { - return false; - } - } - - /** * Deletes the table row corresponding to this active record. * * This method performs the following steps in order: diff --git a/tests/unit/extensions/sphinx/ActiveRecordTest.php b/tests/unit/extensions/sphinx/ActiveRecordTest.php index 8d33e3e..fe15697 100644 --- a/tests/unit/extensions/sphinx/ActiveRecordTest.php +++ b/tests/unit/extensions/sphinx/ActiveRecordTest.php @@ -161,35 +161,12 @@ class ActiveRecordTest extends SphinxTestCase $record2 = RuntimeIndex::find(['id' => 2]); $this->assertEquals(9, $record2->type_id); - // updateCounters - /*$pk = ['id' => 1]; - $record = RuntimeIndex::find($pk); - $this->assertEquals(1, $record->quantity); - $ret = $record->updateCounters(['quantity' => -1]); - $this->assertTrue($ret); - $this->assertEquals(0, $record->quantity); - $record = RuntimeIndex::find($pk); - $this->assertEquals(0, $record->quantity);*/ - // updateAll $pk = ['id' => 2]; $ret = RuntimeIndex::updateAll(['type_id' => 55], $pk); $this->assertEquals(1, $ret); $record = RuntimeIndex::find($pk); $this->assertEquals(55, $record->type_id); - - // updateAllCounters - /*$pk = ['order_id' => 1, 'item_id' => 2]; - $record = RuntimeIndex::find($pk); - $this->assertEquals(2, $record->quantity); - $ret = RuntimeIndex::updateAllCounters([ - 'quantity' => 3, - 'subtotal' => -10, - ], $pk); - $this->assertEquals(1, $ret); - $record = RuntimeIndex::find($pk); - $this->assertEquals(5, $record->quantity); - $this->assertEquals(30, $record->subtotal);*/ } /** From fa086742616fd5ec321051fe218aab794fb7bf98 Mon Sep 17 00:00:00 2001 From: Paul Klimov Date: Fri, 15 Nov 2013 16:31:18 +0200 Subject: [PATCH 25/59] "yii\sphinx\ActiveQuery" refactored. "yii\sphinx\Schema" caching fixed. --- extensions/sphinx/ActiveQuery.php | 119 ++++---------------------------------- extensions/sphinx/Schema.php | 4 +- 2 files changed, 13 insertions(+), 110 deletions(-) diff --git a/extensions/sphinx/ActiveQuery.php b/extensions/sphinx/ActiveQuery.php index 26cc7ab..b1c012f 100644 --- a/extensions/sphinx/ActiveQuery.php +++ b/extensions/sphinx/ActiveQuery.php @@ -7,27 +7,19 @@ namespace yii\sphinx; +use yii\db\ActiveQueryInterface; +use yii\db\ActiveQueryTrait; + /** * Class ActiveQuery * * @author Paul Klimov * @since 2.0 */ -class ActiveQuery extends Query +class ActiveQuery extends Query implements ActiveQueryInterface { - /** - * @var string the name of the ActiveRecord class. - */ - public $modelClass; - /** - * @var array list of relations that this query should be performed with - */ - public $with; - /** - * @var boolean whether to return each record as an array. If false (default), an object - * of [[modelClass]] will be created to represent each record. - */ - public $asArray; + use ActiveQueryTrait; + /** * @var string the SQL statement to be executed for retrieving AR records. * This is set by [[ActiveRecord::findBySql()]]. @@ -35,25 +27,6 @@ class ActiveQuery extends Query public $sql; /** - * PHP magic method. - * This method allows calling static method defined in [[modelClass]] via this query object. - * It is mainly implemented for supporting the feature of scope. - * @param string $name the method name to be called - * @param array $params the parameters passed to the method - * @return mixed the method return result - */ - public function __call($name, $params) - { - if (method_exists($this->modelClass, $name)) { - array_unshift($params, $this); - call_user_func_array([$this->modelClass, $name], $params); - return $this; - } else { - return parent::__call($name, $params); - } - } - - /** * Executes query and returns all results as an array. * @param Connection $db the DB connection used to create the DB command. * If null, the DB connection returned by [[modelClass]] will be used. @@ -65,10 +38,9 @@ class ActiveQuery extends Query $rows = $command->queryAll(); if (!empty($rows)) { $models = $this->createModels($rows); - // TODO relations - /*if (!empty($this->with)) { + if (!empty($this->with)) { $this->populateRelations($models, $this->with); - }*/ + } return $models; } else { return []; @@ -95,12 +67,11 @@ class ActiveQuery extends Query $class = $this->modelClass; $model = $class::create($row); } - // TODO relations - /*if (!empty($this->with)) { + if (!empty($this->with)) { $models = [$model]; $this->populateRelations($models, $this->with); $model = $models[0]; - }*/ + } return $model; } else { return null; @@ -134,74 +105,4 @@ class ActiveQuery extends Query } return $db->createCommand($this->sql, $params); } - - /** - * Sets the [[asArray]] property. - * @param boolean $value whether to return the query results in terms of arrays instead of Active Records. - * @return static the query object itself - */ - public function asArray($value = true) - { - $this->asArray = $value; - return $this; - } - - /** - * Sets the [[indexBy]] property. - * @param string|callable $column the name of the column by which the query results should be indexed by. - * This can also be a callable (e.g. anonymous function) that returns the index value based on the given - * row or model data. The signature of the callable should be: - * - * ~~~ - * // $model is an AR instance when `asArray` is false, - * // or an array of column values when `asArray` is true. - * function ($model) - * { - * // return the index value corresponding to $model - * } - * ~~~ - * - * @return static the query object itself - */ - public function indexBy($column) - { - return parent::indexBy($column); - } - - private function createModels($rows) - { - $models = []; - if ($this->asArray) { - 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) { - $models[] = $class::create($row); - } - } else { - foreach ($rows as $row) { - $model = $class::create($row); - if (is_string($this->indexBy)) { - $key = $model->{$this->indexBy}; - } else { - $key = call_user_func($this->indexBy, $model); - } - $models[$key] = $model; - } - } - } - return $models; - } } \ No newline at end of file diff --git a/extensions/sphinx/Schema.php b/extensions/sphinx/Schema.php index 20675b2..7b84e90 100644 --- a/extensions/sphinx/Schema.php +++ b/extensions/sphinx/Schema.php @@ -121,7 +121,9 @@ class Schema extends Object if ($refresh || ($index = $cache->get($key)) === false) { $index = $this->loadIndexSchema($realName); if ($index !== null) { - $cache->set($key, $index, $db->schemaCacheDuration, new GroupDependency($this->getCacheGroup())); + $cache->set($key, $index, $db->schemaCacheDuration, new GroupDependency([ + 'group' => $this->getCacheGroup(), + ])); } } return $this->_indexes[$name] = $index; From 62968971b6c1b7e08574fd27892481da725a6f54 Mon Sep 17 00:00:00 2001 From: Klimov Paul Date: Sat, 16 Nov 2013 14:54:24 +0200 Subject: [PATCH 26/59] Sphinx unit test "ActiveRelationTest" created. --- tests/unit/data/config.php | 13 +++++ tests/unit/data/sphinx/ar/ArticleDb.php | 13 +++++ tests/unit/data/sphinx/ar/ArticleIndex.php | 13 +++++ tests/unit/data/sphinx/ar/ItemDb.php | 13 +++++ tests/unit/data/sphinx/source.sql | 46 +++++++++++++++ tests/unit/data/sphinx/sphinx.conf | 2 +- tests/unit/data/sphinx/sphinx.sql | 41 ------------- .../unit/extensions/sphinx/ActiveRelationTest.php | 42 +++++++++++++ tests/unit/extensions/sphinx/SphinxTestCase.php | 68 +++++++++++++++++----- 9 files changed, 196 insertions(+), 55 deletions(-) create mode 100644 tests/unit/data/sphinx/ar/ArticleDb.php create mode 100644 tests/unit/data/sphinx/ar/ItemDb.php create mode 100644 tests/unit/data/sphinx/source.sql delete mode 100644 tests/unit/data/sphinx/sphinx.sql create mode 100644 tests/unit/extensions/sphinx/ActiveRelationTest.php diff --git a/tests/unit/data/config.php b/tests/unit/data/config.php index 304c3fc..5fd7e3e 100644 --- a/tests/unit/data/config.php +++ b/tests/unit/data/config.php @@ -30,4 +30,17 @@ return [ 'fixture' => __DIR__ . '/postgres.sql', ], ], + 'sphinx' => [ + 'sphinx' => [ + 'dsn' => 'mysql:host=127.0.0.1;port=9306;', + 'username' => '', + 'password' => '', + ], + 'db' => [ + 'dsn' => 'mysql:host=127.0.0.1;dbname=yiitest', + 'username' => 'travis', + 'password' => '', + 'fixture' => __DIR__ . '/sphinx/source.sql', + ], + ] ]; diff --git a/tests/unit/data/sphinx/ar/ArticleDb.php b/tests/unit/data/sphinx/ar/ArticleDb.php new file mode 100644 index 0000000..de9ca0f --- /dev/null +++ b/tests/unit/data/sphinx/ar/ArticleDb.php @@ -0,0 +1,13 @@ +andWhere('author_id=1'); } + + public function getSource() + { + $config = [ + 'modelClass' => ArticleDb::className(), + 'primaryModel' => $this, + 'link' => ['id' => 'id'], + 'multiple' => false, + ]; + return new ActiveRelation($config); + } } \ No newline at end of file diff --git a/tests/unit/data/sphinx/ar/ItemDb.php b/tests/unit/data/sphinx/ar/ItemDb.php new file mode 100644 index 0000000..128e473 --- /dev/null +++ b/tests/unit/data/sphinx/ar/ItemDb.php @@ -0,0 +1,13 @@ +getConnection(); + ActiveRecordDb::$db = $this->getDbConnection(); + } + + public function testFindLazy() + { + /** @var ArticleIndex $article */ + $article = ArticleIndex::find(['id' => 2]); + $this->assertFalse($article->isRelationPopulated('source')); + $source = $article->source; + $this->assertTrue($article->isRelationPopulated('source')); + $this->assertTrue($source instanceof ArticleDb); + $this->assertEquals(1, count($article->populatedRelations)); + } + + public function testFindEager() + { + $articles = ArticleIndex::find()->with('source')->all(); + $this->assertEquals(2, count($articles)); + $this->assertTrue($articles[0]->isRelationPopulated('source')); + $this->assertTrue($articles[1]->isRelationPopulated('source')); + $this->assertTrue($articles[0]->source instanceof ArticleDb); + $this->assertTrue($articles[1]->source instanceof ArticleDb); + } +} \ No newline at end of file diff --git a/tests/unit/extensions/sphinx/SphinxTestCase.php b/tests/unit/extensions/sphinx/SphinxTestCase.php index 6b936a3..899ea72 100644 --- a/tests/unit/extensions/sphinx/SphinxTestCase.php +++ b/tests/unit/extensions/sphinx/SphinxTestCase.php @@ -21,9 +21,21 @@ class SphinxTestCase extends TestCase 'password' => '', ]; /** - * @var Connection + * @var Connection Sphinx connection instance. */ protected $sphinx; + /** + * @var array Database connection configuration. + */ + protected $dbConfig = [ + 'dsn' => 'mysql:host=127.0.0.1;', + 'username' => '', + 'password' => '', + ]; + /** + * @var \yii\db\Connection database connection instance. + */ + protected $db; public static function setUpBeforeClass() { @@ -33,10 +45,14 @@ class SphinxTestCase extends TestCase protected function setUp() { parent::setUp(); - //$this->sphinxConfig = $this->getParam('sphinx'); if (!extension_loaded('pdo') || !extension_loaded('pdo_mysql')) { $this->markTestSkipped('pdo and pdo_mysql extension are required.'); } + $config = $this->getParam('sphinx'); + if (!empty($config)) { + $this->sphinxConfig = $config['sphinx']; + $this->dbConfig = $config['db']; + } $this->mockApplication(); static::loadClassMap(); } @@ -67,15 +83,15 @@ class SphinxTestCase extends TestCase /** * @param bool $reset whether to clean up the test database - * @param bool $open whether to open and populate test database + * @param bool $open whether to open test database * @return \yii\sphinx\Connection */ - public function getConnection($reset = true, $open = true) + public function getConnection($reset = false, $open = true) { if (!$reset && $this->sphinx) { return $this->sphinx; } - $db = new \yii\sphinx\Connection; + $db = new Connection; $db->dsn = $this->sphinxConfig['dsn']; if (isset($this->sphinxConfig['username'])) { $db->username = $this->sphinxConfig['username']; @@ -86,14 +102,6 @@ class SphinxTestCase extends TestCase } if ($open) { $db->open(); - if (!empty($this->sphinxConfig['fixture'])) { - $lines = explode(';', file_get_contents($this->sphinxConfig['fixture'])); - foreach ($lines as $line) { - if (trim($line) !== '') { - $db->pdo->exec($line); - } - } - } } $this->sphinx = $db; return $db; @@ -109,4 +117,38 @@ class SphinxTestCase extends TestCase $this->sphinx->createCommand('TRUNCATE RTINDEX ' . $indexName)->execute(); } } + + /** + * @param bool $reset whether to clean up the test database + * @param bool $open whether to open and populate test database + * @return \yii\db\Connection + */ + public function getDbConnection($reset = true, $open = true) + { + if (!$reset && $this->db) { + return $this->db; + } + $db = new \yii\db\Connection; + $db->dsn = $this->dbConfig['dsn']; + if (isset($this->dbConfig['username'])) { + $db->username = $this->dbConfig['username']; + $db->password = $this->dbConfig['password']; + } + if (isset($this->dbConfig['attributes'])) { + $db->attributes = $this->dbConfig['attributes']; + } + if ($open) { + $db->open(); + if (!empty($this->dbConfig['fixture'])) { + $lines = explode(';', file_get_contents($this->dbConfig['fixture'])); + foreach ($lines as $line) { + if (trim($line) !== '') { + $db->pdo->exec($line); + } + } + } + } + $this->db = $db; + return $db; + } } \ No newline at end of file From f228f21541850964cf2cd7d40f9f042819c1b5e5 Mon Sep 17 00:00:00 2001 From: Klimov Paul Date: Sat, 16 Nov 2013 15:10:33 +0200 Subject: [PATCH 27/59] "ActiveRelationInterface" applied to Sphinx Active Record. --- extensions/sphinx/ActiveRecord.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/extensions/sphinx/ActiveRecord.php b/extensions/sphinx/ActiveRecord.php index f821200..08617b4 100644 --- a/extensions/sphinx/ActiveRecord.php +++ b/extensions/sphinx/ActiveRecord.php @@ -12,6 +12,7 @@ use yii\base\InvalidParamException; use yii\base\Model; use yii\base\ModelEvent; use yii\base\UnknownMethodException; +use yii\db\ActiveRelationInterface; use yii\db\Expression; use yii\db\StaleObjectException; use yii\helpers\Inflector; @@ -324,8 +325,7 @@ class ActiveRecord extends Model return $this->_related[$name]; } $value = parent::__get($name); - // TODO: relation - if ($value instanceof ActiveRelation) { + if ($value instanceof ActiveRelationInterface) { return $this->_related[$name] = $value->multiple ? $value->all() : $value->one(); } else { return $value; @@ -1094,10 +1094,10 @@ class ActiveRecord extends Model /** * Returns the relation object with the specified name. - * A relation is defined by a getter method which returns an [[ActiveRelation]] object. + * A relation is defined by a getter method which returns an [[ActiveRelationInterface]] object. * It can be declared in either the Active Record class itself or one of its behaviors. * @param string $name the relation name - * @return ActiveRelation the relation object + * @return ActiveRelationInterface the relation object * @throws InvalidParamException if the named relation does not exist. */ public function getRelation($name) @@ -1105,7 +1105,7 @@ class ActiveRecord extends Model $getter = 'get' . $name; try { $relation = $this->$getter(); - if ($relation instanceof ActiveRelation) { + if ($relation instanceof ActiveRelationInterface) { return $relation; } else { return null; From 0bc91f29e7baa562e9ab06d5a51830dabc1692be Mon Sep 17 00:00:00 2001 From: Klimov Paul Date: Sat, 16 Nov 2013 15:32:03 +0200 Subject: [PATCH 28/59] Sphinx Active Record relation handling provided. --- extensions/sphinx/ActiveRecord.php | 23 +++++++++++++++++++++++ tests/unit/data/sphinx/ar/ArticleDb.php | 4 ++-- tests/unit/data/sphinx/ar/ItemDb.php | 4 ++-- 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/extensions/sphinx/ActiveRecord.php b/extensions/sphinx/ActiveRecord.php index 08617b4..690a59d 100644 --- a/extensions/sphinx/ActiveRecord.php +++ b/extensions/sphinx/ActiveRecord.php @@ -1126,4 +1126,27 @@ class ActiveRecord extends Model $transactions = $this->transactions(); return isset($transactions[$scenario]) && ($transactions[$scenario] & $operation); } + + /** + * Sets the element at the specified offset. + * This method is required by the SPL interface `ArrayAccess`. + * It is implicitly called when you use something like `$model[$offset] = $item;`. + * @param integer $offset the offset to set element + * @param mixed $item the element value + * @throws \Exception on failure + */ + public function offsetSet($offset, $item) + { + // Bypass relation owner restriction to 'yii\db\ActiveRecord' at [[ActiveRelationTrait::findWith()]]: + try { + $relation = $this->getRelation($offset); + if (is_object($relation)) { + $this->populateRelation($offset, $item); + return; + } + } catch (UnknownMethodException $e) { + throw $e->getPrevious(); + } + parent::offsetSet($offset, $item); + } } \ No newline at end of file diff --git a/tests/unit/data/sphinx/ar/ArticleDb.php b/tests/unit/data/sphinx/ar/ArticleDb.php index de9ca0f..3c32f99 100644 --- a/tests/unit/data/sphinx/ar/ArticleDb.php +++ b/tests/unit/data/sphinx/ar/ArticleDb.php @@ -2,9 +2,9 @@ namespace yiiunit\data\sphinx\ar; -use yiiunit\data\ar; +use yiiunit\data\ar\ActiveRecord as ActiveRecordDb; -class ArticleDb extends ActiveRecord +class ArticleDb extends ActiveRecordDb { public static function tableName() { diff --git a/tests/unit/data/sphinx/ar/ItemDb.php b/tests/unit/data/sphinx/ar/ItemDb.php index 128e473..ddf7eea 100644 --- a/tests/unit/data/sphinx/ar/ItemDb.php +++ b/tests/unit/data/sphinx/ar/ItemDb.php @@ -2,9 +2,9 @@ namespace yiiunit\data\sphinx\ar; -use yiiunit\data\ar; +use yiiunit\data\ar\ActiveRecord as ActiveRecordDb; -class ItemDb extends ActiveRecord +class ItemDb extends ActiveRecordDb { public static function tableName() { From d6ddfd7bb39b25f80a76ed381732108107a72006 Mon Sep 17 00:00:00 2001 From: Klimov Paul Date: Sat, 16 Nov 2013 17:14:15 +0200 Subject: [PATCH 29/59] Methods "callSnippets()" and "callKeywords()" added to Sphinx Active Record --- extensions/sphinx/ActiveRecord.php | 91 ++++++++++++++++++++++- tests/unit/extensions/sphinx/ActiveRecordTest.php | 23 ++++++ 2 files changed, 113 insertions(+), 1 deletion(-) diff --git a/extensions/sphinx/ActiveRecord.php b/extensions/sphinx/ActiveRecord.php index 690a59d..58325c6 100644 --- a/extensions/sphinx/ActiveRecord.php +++ b/extensions/sphinx/ActiveRecord.php @@ -11,6 +11,7 @@ use yii\base\InvalidConfigException; use yii\base\InvalidParamException; use yii\base\Model; use yii\base\ModelEvent; +use yii\base\NotSupportedException; use yii\base\UnknownMethodException; use yii\db\ActiveRelationInterface; use yii\db\Expression; @@ -92,6 +93,10 @@ class ActiveRecord extends Model * @var array related models indexed by the relation names */ private $_related = []; + /** + * @var string snippet value for this Active Record instance. + */ + private $_snippet; /** * Returns the Sphinx connection used by this AR class. @@ -248,6 +253,90 @@ class ActiveRecord extends Model } /** + * Builds a snippet from provided data and query, using specified index settings. + * @param string|array $source is the source data to extract a snippet from. + * It could be either a single string or array of strings. + * @param string $query the full-text query to build snippets for. + * @param array $options list of options in format: optionName => optionValue + * @return string|array built snippet in case "source" is a string, list of built snippets + * in case "source" is an array. + */ + public static function callSnippets($source, $query, $options = []) + { + $command = static::getDb()->createCommand(); + $command->callSnippets(static::indexName(), $source, $query, $options); + if (is_array($source)) { + return $command->queryColumn(); + } else { + return $command->queryScalar(); + } + } + + /** + * Returns tokenized and normalized forms of the keywords, and, optionally, keyword statistics. + * @param string $text the text to break down to keywords. + * @param boolean $fetchStatistic whether to return document and hit occurrence statistics + * @return array keywords and statistics + */ + public static function callKeywords($text, $fetchStatistic = false) + { + $command = static::getDb()->createCommand(); + $command->callKeywords(static::indexName(), $text, $fetchStatistic); + return $command->queryAll(); + } + + /** + * @param string $snippet + */ + public function setSnippet($snippet) + { + $this->_snippet = $snippet; + } + + /** + * @param string $query snippet source query + * @param array $options list of options in format: optionName => optionValue + * @return string snippet value + */ + public function getSnippet($query = null, $options = []) + { + if ($query !== null) { + $this->_snippet = $this->fetchSnippet($query, $options); + } + return $this->_snippet; + } + + /** + * Builds up the snippet value from the given query. + * @param string $query the full-text query to build snippets for. + * @param array $options list of options in format: optionName => optionValue + * @return string snippet value. + */ + protected function fetchSnippet($query, $options = []) + { + return static::callSnippets($this->getSnippetSource(), $query, $options); + } + + /** + * Returns the string, which should be used as a source to create snippet for this + * Active Record instance. + * Child classes must implement this method to return the actual snippet source text. + * For example: + * ```php + * public function getSnippetSource() + * { + * return $this->snippetSourceRelation->content; + * } + * ``` + * @return string snippet source string. + * @throws \yii\base\NotSupportedException if this is not supported by the Active Record class + */ + public function getSnippetSource() + { + throw new NotSupportedException($this->className() . ' does not provide snippet source.'); + } + + /** * Returns the name of the column that stores the lock version for implementing optimistic locking. * * Optimistic locking allows multiple users to access the same record for edits and avoids @@ -1137,7 +1226,7 @@ class ActiveRecord extends Model */ public function offsetSet($offset, $item) { - // Bypass relation owner restriction to 'yii\db\ActiveRecord' at [[ActiveRelationTrait::findWith()]]: + // Bypass relation owner restriction to 'yii\db\ActiveRecord' at [[yii\db\ActiveRelationTrait::findWith()]]: try { $relation = $this->getRelation($offset); if (is_object($relation)) { diff --git a/tests/unit/extensions/sphinx/ActiveRecordTest.php b/tests/unit/extensions/sphinx/ActiveRecordTest.php index fe15697..815ebe8 100644 --- a/tests/unit/extensions/sphinx/ActiveRecordTest.php +++ b/tests/unit/extensions/sphinx/ActiveRecordTest.php @@ -202,4 +202,27 @@ class ActiveRecordTest extends SphinxTestCase $records = RuntimeIndex::find()->all(); $this->assertEquals(0, count($records)); } + + public function testCallSnippets() + { + $query = 'pencil'; + $source = 'Some data sentence about ' . $query; + + $snippet = ArticleIndex::callSnippets($source, $query); + $this->assertNotEmpty($snippet, 'Unable to call snippets!'); + $this->assertContains('' . $query . '', $snippet, 'Query not present in the snippet!'); + + $rows = ArticleIndex::callSnippets([$source], $query); + $this->assertNotEmpty($rows, 'Unable to call snippets!'); + $this->assertContains('' . $query . '', $rows[0], 'Query not present in the snippet!'); + } + + public function testCallKeywords() + { + $text = 'table pencil'; + $rows = ArticleIndex::callKeywords($text); + $this->assertNotEmpty($rows, 'Unable to call keywords!'); + $this->assertArrayHasKey('tokenized', $rows[0], 'No tokenized keyword!'); + $this->assertArrayHasKey('normalized', $rows[0], 'No normalized keyword!'); + } } \ No newline at end of file From 7fb4bfdd216a18430940bc4f2cd2bedb1f78e7e8 Mon Sep 17 00:00:00 2001 From: Klimov Paul Date: Sun, 17 Nov 2013 14:09:22 +0200 Subject: [PATCH 30/59] Sphinx Active Query fixed --- extensions/sphinx/ActiveQuery.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/extensions/sphinx/ActiveQuery.php b/extensions/sphinx/ActiveQuery.php index b1c012f..91ed96f 100644 --- a/extensions/sphinx/ActiveQuery.php +++ b/extensions/sphinx/ActiveQuery.php @@ -39,7 +39,7 @@ class ActiveQuery extends Query implements ActiveQueryInterface if (!empty($rows)) { $models = $this->createModels($rows); if (!empty($this->with)) { - $this->populateRelations($models, $this->with); + $this->findWith($this->with, $models); } return $models; } else { @@ -69,7 +69,7 @@ class ActiveQuery extends Query implements ActiveQueryInterface } if (!empty($this->with)) { $models = [$model]; - $this->populateRelations($models, $this->with); + $this->findWith($this->with, $models); $model = $models[0]; } return $model; From 386b58b2a1103a77dc4b1c4bf49d9d6d72281739 Mon Sep 17 00:00:00 2001 From: Klimov Paul Date: Sun, 17 Nov 2013 16:26:58 +0200 Subject: [PATCH 31/59] Snippet call options added to Sphinx Query. --- extensions/sphinx/Query.php | 131 +++++++++++++++++++++++++++-- tests/unit/extensions/sphinx/QueryTest.php | 33 ++++++++ 2 files changed, 156 insertions(+), 8 deletions(-) diff --git a/extensions/sphinx/Query.php b/extensions/sphinx/Query.php index ecb5388..1d66887 100644 --- a/extensions/sphinx/Query.php +++ b/extensions/sphinx/Query.php @@ -108,20 +108,76 @@ class Query extends Component * row data. For more details, see [[indexBy()]]. This property is only used by [[all()]]. */ public $indexBy; + /** + * @var callback PHP callback, which should be used to fetch source data for the snippets. + * Such callback will receive array of query result rows as an argument and must return the + * array of snippet source strings in the order, which match one of incoming rows. + * For example: + * ```php + * $query = new Query; + * $query->from('idx_item') + * ->match('pencil') + * ->snippetCallback(function ($rows) { + * $result = []; + * foreach ($rows as $row) { + * $result[] = file_get_contents('/path/to/index/files/' . $row['id'] . '.txt'); + * } + * return $result; + * }) + * ->all(); + * ``` + */ + public $snippetCallback; + /** + * @var array query options for the call snippet. + */ + public $snippetOptions; + /** + * @var Connection the Sphinx connection used to generate the SQL statements. + */ + private $_connection; + + /** + * @param Connection $connection Sphinx connection instance + * @return static the query object itself + */ + public function setConnection($connection) + { + $this->_connection = $connection; + return $this; + } + + /** + * @return Connection Sphinx connection instance + */ + public function getConnection() + { + if ($this->_connection === null) { + $this->_connection = $this->defaultConnection(); + } + return $this->_connection; + } + + /** + * @return Connection default connection value. + */ + protected function defaultConnection() + { + return Yii::$app->getComponent('sphinx'); + } /** * Creates a Sphinx command that can be used to execute this query. - * @param Connection $sphinxConnection the Sphinx connection used to generate the SQL statement. + * @param Connection $connection the Sphinx connection used to generate the SQL statement. * If this parameter is not given, the `sphinx` application component will be used. * @return Command the created Sphinx command instance. */ - public function createCommand($sphinxConnection = null) + public function createCommand($connection = null) { - if ($sphinxConnection === null) { - $sphinxConnection = Yii::$app->getComponent('sphinx'); - } - list ($sql, $params) = $sphinxConnection->getQueryBuilder()->build($this); - return $sphinxConnection->createCommand($sql, $params); + $this->setConnection($connection); + $connection = $this->getConnection(); + list ($sql, $params) = $connection->getQueryBuilder()->build($this); + return $connection->createCommand($sql, $params); } /** @@ -154,6 +210,7 @@ class Query extends Component public function all($db = null) { $rows = $this->createCommand($db)->queryAll(); + $rows = $this->fillUpSnippets($rows); if ($this->indexBy === null) { return $rows; } @@ -178,7 +235,11 @@ class Query extends Component */ public function one($db = null) { - return $this->createCommand($db)->queryOne(); + $result = $this->createCommand($db)->queryOne(); + if ($result) { + list ($result) = $this->fillUpSnippets([$result]); + } + return $result; } /** @@ -686,4 +747,58 @@ class Query extends Component } return $this; } + + /** + * @param callback $callback + * @return static the query object itself + */ + public function snippetCallback($callback) + { + $this->snippetCallback = $callback; + return $this; + } + + /** + * @param array $options + * @return static the query object itself + */ + public function snippetOptions($options) + { + $this->snippetOptions = $options; + return $this; + } + + /** + * Fills the query result rows with the snippets built from source determined by + * [[snippetCallback]] result. + * @param array $rows raw query result rows. + * @return array query result rows with filled up snippets. + */ + protected function fillUpSnippets($rows) + { + if ($this->snippetCallback === null) { + return $rows; + } + $snippetSources = call_user_func($this->snippetCallback, $rows); + $snippets = $this->callSnippets($snippetSources); + $snippetKey = 0; + foreach ($rows as $key => $row) { + $rows[$key]['snippet'] = $snippets[$snippetKey]; + $snippetKey++; + } + return $rows; + } + + /** + * Builds a snippets from provided source data. + * @param array $source the source data to extract a snippet from. + * @return array snippets list + */ + protected function callSnippets(array $source) + { + $connection = $this->getConnection(); + return $connection->createCommand() + ->callSnippets($this->from[0], $source, $this->match, $this->snippetOptions) + ->queryColumn(); + } } \ No newline at end of file diff --git a/tests/unit/extensions/sphinx/QueryTest.php b/tests/unit/extensions/sphinx/QueryTest.php index cc3e27a..f95e35d 100644 --- a/tests/unit/extensions/sphinx/QueryTest.php +++ b/tests/unit/extensions/sphinx/QueryTest.php @@ -151,4 +151,37 @@ class QueryTest extends SphinxTestCase ->all($connection); $this->assertNotEmpty($rows); } + + /** + * @depends testRun + */ + public function testSnippet() + { + $connection = $this->getConnection(); + + $match = 'about'; + $snippetPrefix = 'snippet#'; + $snippetCallback = function() use ($match, $snippetPrefix) { + return [ + $snippetPrefix . '1: ' . $match, + $snippetPrefix . '2: ' . $match, + ]; + }; + $snippetOptions = [ + 'before_match' => '[', + 'after_match' => ']', + ]; + + $query = new Query; + $rows = $query->from('yii2_test_article_index') + ->match($match) + ->snippetCallback($snippetCallback) + ->snippetOptions($snippetOptions) + ->all($connection); + $this->assertNotEmpty($rows); + foreach ($rows as $row) { + $this->assertContains($snippetPrefix, $row['snippet'], 'Snippet source not present!'); + $this->assertContains($snippetOptions['before_match'] . $match, $row['snippet'] . $snippetOptions['after_match'], 'Options not applied!'); + } + } } \ No newline at end of file From e1065ef1fc17599a99f32c428a7ab54fee020d7d Mon Sep 17 00:00:00 2001 From: Klimov Paul Date: Sun, 17 Nov 2013 17:02:47 +0200 Subject: [PATCH 32/59] "snippetByModel" option added to Sphinx Active Query. --- extensions/sphinx/ActiveQuery.php | 43 ++++++++++++++++++++-- extensions/sphinx/Query.php | 15 +++++--- tests/unit/data/sphinx/ar/ArticleIndex.php | 5 +++ .../unit/extensions/sphinx/ActiveRelationTest.php | 13 +++++++ 4 files changed, 68 insertions(+), 8 deletions(-) diff --git a/extensions/sphinx/ActiveQuery.php b/extensions/sphinx/ActiveQuery.php index 91ed96f..aaaf419 100644 --- a/extensions/sphinx/ActiveQuery.php +++ b/extensions/sphinx/ActiveQuery.php @@ -27,6 +27,19 @@ class ActiveQuery extends Query implements ActiveQueryInterface public $sql; /** + * Sets the [[snippetCallback]] to [[fetchSnippetSourceFromModels]], which allows to + * fetch the snippet source strings from the Active Record models, using method + * [[ActiveRecord::getSnippetSource()]]. + * Warning: this option should NOT be used with [[asArray]] at the same time! + * @return static the query object itself + */ + public function snippetByModel() + { + $this->snippetCallback(array($this, 'fetchSnippetSourceFromModels')); + return $this; + } + + /** * Executes query and returns all results as an array. * @param Connection $db the DB connection used to create the DB command. * If null, the DB connection returned by [[modelClass]] will be used. @@ -41,6 +54,7 @@ class ActiveQuery extends Query implements ActiveQueryInterface if (!empty($this->with)) { $this->findWith($this->with, $models); } + $models = $this->fillUpSnippets($models); return $models; } else { return []; @@ -72,6 +86,7 @@ class ActiveQuery extends Query implements ActiveQueryInterface $this->findWith($this->with, $models); $model = $models[0]; } + list ($model) = $this->fillUpSnippets([$model]); return $model; } else { return null; @@ -88,9 +103,8 @@ class ActiveQuery extends Query implements ActiveQueryInterface { /** @var $modelClass ActiveRecord */ $modelClass = $this->modelClass; - if ($db === null) { - $db = $modelClass::getDb(); - } + $this->setConnection($db); + $db = $this->getConnection(); $params = $this->params; if ($this->sql === null) { @@ -105,4 +119,27 @@ class ActiveQuery extends Query implements ActiveQueryInterface } return $db->createCommand($this->sql, $params); } + + /** + * @inheritdoc + */ + protected function defaultConnection() + { + $modelClass = $this->modelClass; + return $modelClass::getDb(); + } + + /** + * Fetches the source for the snippets using [[ActiveRecord::getSnippetSource()]] method. + * @param ActiveRecord[] $models raw query result rows. + * @return array snippet source strings + */ + protected function fetchSnippetSourceFromModels($models) + { + $result = []; + foreach ($models as $model) { + $result[] = $model->getSnippetSource(); + } + return $result; + } } \ No newline at end of file diff --git a/extensions/sphinx/Query.php b/extensions/sphinx/Query.php index 1d66887..fa67ad2 100644 --- a/extensions/sphinx/Query.php +++ b/extensions/sphinx/Query.php @@ -9,6 +9,7 @@ namespace yii\sphinx; use Yii; use yii\base\Component; +use yii\base\InvalidCallException; use yii\db\Expression; /** @@ -235,11 +236,11 @@ class Query extends Component */ public function one($db = null) { - $result = $this->createCommand($db)->queryOne(); - if ($result) { - list ($result) = $this->fillUpSnippets([$result]); + $row = $this->createCommand($db)->queryOne(); + if ($row !== false) { + list ($row) = $this->fillUpSnippets([$row]); } - return $result; + return $row; } /** @@ -797,8 +798,12 @@ class Query extends Component protected function callSnippets(array $source) { $connection = $this->getConnection(); + $match = $this->match; + if ($match === null) { + throw new InvalidCallException('Unable to call snippets: "' . $this->className() . '::match" should be specified.'); + } return $connection->createCommand() - ->callSnippets($this->from[0], $source, $this->match, $this->snippetOptions) + ->callSnippets($this->from[0], $source, $match, $this->snippetOptions) ->queryColumn(); } } \ No newline at end of file diff --git a/tests/unit/data/sphinx/ar/ArticleIndex.php b/tests/unit/data/sphinx/ar/ArticleIndex.php index f7f0ef7..7c8c079 100644 --- a/tests/unit/data/sphinx/ar/ArticleIndex.php +++ b/tests/unit/data/sphinx/ar/ArticleIndex.php @@ -28,4 +28,9 @@ class ArticleIndex extends ActiveRecord ]; return new ActiveRelation($config); } + + public function getSnippetSource() + { + return $this->source->content; + } } \ No newline at end of file diff --git a/tests/unit/extensions/sphinx/ActiveRelationTest.php b/tests/unit/extensions/sphinx/ActiveRelationTest.php index b304086..343a58b 100644 --- a/tests/unit/extensions/sphinx/ActiveRelationTest.php +++ b/tests/unit/extensions/sphinx/ActiveRelationTest.php @@ -39,4 +39,17 @@ class ActiveRelationTest extends SphinxTestCase $this->assertTrue($articles[0]->source instanceof ArticleDb); $this->assertTrue($articles[1]->source instanceof ArticleDb); } + + /** + * @depends testFindEager + */ + public function testFindWithSnippets() + { + $articles = ArticleIndex::find() + ->match('about') + ->with('source') + ->snippetByModel() + ->all(); + $this->assertEquals(2, count($articles)); + } } \ No newline at end of file From bbb5e1dba262f9d237784c3d18680476aede390f Mon Sep 17 00:00:00 2001 From: Paul Klimov Date: Mon, 18 Nov 2013 12:50:19 +0200 Subject: [PATCH 33/59] Redundant support of schema name removed from Sphinx --- extensions/sphinx/IndexSchema.php | 4 ---- extensions/sphinx/QueryBuilder.php | 1 - extensions/sphinx/Schema.php | 45 ++++++++++---------------------------- 3 files changed, 11 insertions(+), 39 deletions(-) diff --git a/extensions/sphinx/IndexSchema.php b/extensions/sphinx/IndexSchema.php index 3ce090d..39dbf26 100644 --- a/extensions/sphinx/IndexSchema.php +++ b/extensions/sphinx/IndexSchema.php @@ -21,10 +21,6 @@ use yii\base\InvalidParamException; class IndexSchema extends Object { /** - * @var string name of the schema that this index belongs to. - */ - public $schemaName; - /** * @var string name of this index. */ public $name; diff --git a/extensions/sphinx/QueryBuilder.php b/extensions/sphinx/QueryBuilder.php index a1c572b..be2f00f 100644 --- a/extensions/sphinx/QueryBuilder.php +++ b/extensions/sphinx/QueryBuilder.php @@ -581,7 +581,6 @@ class QueryBuilder extends Object return is_array($columns) ? implode(', ', $columns) : $columns; } - /** * Parses the condition specification and generates the corresponding SQL expression. * @param string|array $condition the condition specification. Please refer to [[Query::where()]] diff --git a/extensions/sphinx/Schema.php b/extensions/sphinx/Schema.php index 7b84e90..a23953e 100644 --- a/extensions/sphinx/Schema.php +++ b/extensions/sphinx/Schema.php @@ -38,7 +38,7 @@ class Schema extends Object /** * @var array list of ALL index names in the Sphinx */ - private $_indexNames = []; + private $_indexNames; /** * @var array list of loaded index metadata (index name => IndexSchema) */ @@ -83,19 +83,13 @@ class Schema extends Object } /** - * Resolves the index name and schema name (if any). + * Resolves the index name. * @param IndexSchema $index the index metadata object * @param string $name the index name */ protected function resolveIndexNames($index, $name) { - $parts = explode('.', str_replace('`', '', $name)); - if (isset($parts[1])) { - $index->schemaName = $parts[0]; - $index->name = $parts[1]; - } else { - $index->name = $parts[0]; - } + $index->name = str_replace('`', '', $name); } /** @@ -163,19 +157,15 @@ class Schema extends Object /** * Returns the metadata for all indexes in the database. - * @param string $schema the schema of the indexes. Defaults to empty string, meaning the current or default schema name. * @param boolean $refresh whether to fetch the latest available index schemas. If this is false, * cached data may be returned if available. * @return IndexSchema[] the metadata for all indexes in the Sphinx. * Each array element is an instance of [[IndexSchema]] or its child class. */ - public function getTableSchemas($schema = '', $refresh = false) + public function getTableSchemas($refresh = false) { $indexes = []; - foreach ($this->getIndexNames($schema, $refresh) as $name) { - if ($schema !== '') { - $name = $schema . '.' . $name; - } + foreach ($this->getIndexNames($refresh) as $name) { if (($index = $this->getIndexSchema($name, $refresh)) !== null) { $indexes[] = $index; } @@ -185,31 +175,25 @@ class Schema extends Object /** * Returns all index names in the database. - * @param string $schema the schema of the indexes. Defaults to empty string, meaning the current or default schema name. - * If not empty, the returned index names will be prefixed with the schema name. * @param boolean $refresh whether to fetch the latest available index names. If this is false, * index names fetched previously (if available) will be returned. * @return string[] all index names in the database. */ - public function getIndexNames($schema = '', $refresh = false) + public function getIndexNames($refresh = false) { - if (!isset($this->_indexNames[$schema]) || $refresh) { - $this->_indexNames[$schema] = $this->findIndexNames($schema); + if (!isset($this->_indexNames) || $refresh) { + $this->_indexNames = $this->findIndexNames(); } - return $this->_indexNames[$schema]; + return $this->_indexNames; } /** * Returns all index names in the database. - * @param string $schema the schema of the indexes. Defaults to empty string, meaning the current or default schema. * @return array all index names in the database. The names have NO schema name prefix. */ - protected function findIndexNames($schema = '') + protected function findIndexNames() { $sql = 'SHOW TABLES'; - if ($schema !== '') { - $sql .= ' FROM ' . $this->quoteSimpleIndexName($schema); - } return $this->db->createCommand($sql)->queryColumn(); } @@ -304,14 +288,7 @@ class Schema extends Object if (strpos($name, '(') !== false || strpos($name, '{{') !== false) { return $name; } - if (strpos($name, '.') === false) { - return $this->quoteSimpleIndexName($name); - } - $parts = explode('.', $name); - foreach ($parts as $i => $part) { - $parts[$i] = $this->quoteSimpleIndexName($part); - } - return implode('.', $parts); + return $this->quoteSimpleIndexName($name); } /** From 102f3868730160492a4e1c94170e0395cbf881fd Mon Sep 17 00:00:00 2001 From: Paul Klimov Date: Mon, 18 Nov 2013 13:36:17 +0200 Subject: [PATCH 34/59] Index schema type support added to Sphinx --- extensions/sphinx/IndexSchema.php | 8 ++++ extensions/sphinx/Schema.php | 61 +++++++++++++++++++++++++---- tests/unit/extensions/sphinx/SchemaTest.php | 15 ++++++- 3 files changed, 75 insertions(+), 9 deletions(-) diff --git a/extensions/sphinx/IndexSchema.php b/extensions/sphinx/IndexSchema.php index 39dbf26..2908e82 100644 --- a/extensions/sphinx/IndexSchema.php +++ b/extensions/sphinx/IndexSchema.php @@ -25,6 +25,14 @@ class IndexSchema extends Object */ public $name; /** + * @var string type of the index. + */ + public $type; + /** + * @var boolean whether this index is a runtime index. + */ + public $isRuntime; + /** * @var string primary key of this index. */ public $primaryKey; diff --git a/extensions/sphinx/Schema.php b/extensions/sphinx/Schema.php index a23953e..856f07f 100644 --- a/extensions/sphinx/Schema.php +++ b/extensions/sphinx/Schema.php @@ -40,6 +40,10 @@ class Schema extends Object */ private $_indexNames; /** + * @var array list of ALL index types in the Sphinx (index name => index type) + */ + private $_indexTypes; + /** * @var array list of loaded index metadata (index name => IndexSchema) */ private $_indexes = []; @@ -74,6 +78,7 @@ class Schema extends Object { $index = new IndexSchema; $this->resolveIndexNames($index, $name); + $this->resolveIndexType($index); if ($this->findColumns($index)) { return $index; @@ -93,6 +98,17 @@ class Schema extends Object } /** + * Resolves the index name. + * @param IndexSchema $index the index metadata object + */ + protected function resolveIndexType($index) + { + $indexTypes = $this->getIndexTypes(); + $index->type = array_key_exists($index->name, $indexTypes) ? $indexTypes[$index->name] : 'unknown'; + $index->isRuntime = ($index->type == 'rt'); + } + + /** * Obtains the metadata for the named index. * @param string $name index name. The index name may contain schema name if any. Do not quote the index name. * @param boolean $refresh whether to reload the index schema even if it is found in the cache. @@ -162,7 +178,7 @@ class Schema extends Object * @return IndexSchema[] the metadata for all indexes in the Sphinx. * Each array element is an instance of [[IndexSchema]] or its child class. */ - public function getTableSchemas($refresh = false) + public function getIndexSchemas($refresh = false) { $indexes = []; foreach ($this->getIndexNames($refresh) as $name) { @@ -174,27 +190,56 @@ class Schema extends Object } /** - * Returns all index names in the database. + * Returns all index names in the Sphinx. * @param boolean $refresh whether to fetch the latest available index names. If this is false, * index names fetched previously (if available) will be returned. - * @return string[] all index names in the database. + * @return string[] all index names in the Sphinx. */ public function getIndexNames($refresh = false) { if (!isset($this->_indexNames) || $refresh) { - $this->_indexNames = $this->findIndexNames(); + $this->initIndexesInfo(); } return $this->_indexNames; } /** - * Returns all index names in the database. - * @return array all index names in the database. The names have NO schema name prefix. + * Returns all index types in the Sphinx. + * @param boolean $refresh whether to fetch the latest available index types. If this is false, + * index types fetched previously (if available) will be returned. + * @return array all index types in the Sphinx in format: index name => index type. + */ + public function getIndexTypes($refresh = false) + { + if (!isset($this->_indexTypes) || $refresh) { + $this->initIndexesInfo(); + } + return $this->_indexTypes; + } + + /** + * Initializes information about name and type of all index in the Sphinx. + */ + protected function initIndexesInfo() + { + $this->_indexNames = []; + $this->_indexTypes = []; + $indexes = $this->findIndexes(); + foreach ($indexes as $index) { + $indexName = $index['Index']; + $this->_indexNames[] = $indexName; + $this->_indexTypes[$indexName] = $index['Type']; + } + } + + /** + * Returns all index names in the Sphinx. + * @return array all index names in the Sphinx. */ - protected function findIndexNames() + protected function findIndexes() { $sql = 'SHOW TABLES'; - return $this->db->createCommand($sql)->queryColumn(); + return $this->db->createCommand($sql)->queryAll(); } /** diff --git a/tests/unit/extensions/sphinx/SchemaTest.php b/tests/unit/extensions/sphinx/SchemaTest.php index cdb185a..2cc3ff9 100644 --- a/tests/unit/extensions/sphinx/SchemaTest.php +++ b/tests/unit/extensions/sphinx/SchemaTest.php @@ -24,7 +24,7 @@ class SchemaTest extends SphinxTestCase { $schema = $this->getConnection()->schema; - $indexes = $schema->getTableSchemas(); + $indexes = $schema->getIndexSchemas(); $this->assertEquals(count($schema->getIndexNames()), count($indexes)); foreach($indexes as $index) { $this->assertInstanceOf('yii\sphinx\IndexSchema', $index); @@ -68,4 +68,17 @@ class SchemaTest extends SphinxTestCase } fclose($fp); } + + public function testIndexType() + { + $schema = $this->getConnection()->schema; + + $index = $schema->getIndexSchema('yii2_test_article_index'); + $this->assertEquals('local', $index->type); + $this->assertFalse($index->isRuntime); + + $index = $schema->getIndexSchema('yii2_test_rt_index'); + $this->assertEquals('rt', $index->type); + $this->assertTrue($index->isRuntime); + } } \ No newline at end of file From cd3950aaa71ba30be210e012a633614a1f0bdc9a Mon Sep 17 00:00:00 2001 From: Paul Klimov Date: Mon, 18 Nov 2013 14:03:42 +0200 Subject: [PATCH 35/59] Sphinx Active Record saving advanced to use 'replace' for runtime indexes --- extensions/sphinx/ActiveRecord.php | 34 ++++++++++++++++++++++------------ 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/extensions/sphinx/ActiveRecord.php b/extensions/sphinx/ActiveRecord.php index 58325c6..5256c1c 100644 --- a/extensions/sphinx/ActiveRecord.php +++ b/extensions/sphinx/ActiveRecord.php @@ -850,20 +850,30 @@ class ActiveRecord extends Model $this->afterSave(false); return 0; } - $condition = $this->getOldPrimaryKey(true); - $lock = $this->optimisticLock(); - if ($lock !== null) { - if (!isset($values[$lock])) { - $values[$lock] = $this->$lock + 1; + + if ($this->getIndexSchema()->isRuntime) { + $values = array_merge($values, $this->getOldPrimaryKey(true)); + $command = static::getDb()->createCommand(); + $command->replace(static::indexName(), $values); + // We do not check the return value of replace because it's possible + // that the REPLACE statement doesn't change anything and thus returns 0. + $rows = $command->execute(); + } else { + $condition = $this->getOldPrimaryKey(true); + $lock = $this->optimisticLock(); + if ($lock !== null) { + if (!isset($values[$lock])) { + $values[$lock] = $this->$lock + 1; + } + $condition[$lock] = $this->$lock; } - $condition[$lock] = $this->$lock; - } - // We do not check the return value of updateAll() because it's possible - // that the UPDATE statement doesn't change anything and thus returns 0. - $rows = $this->updateAll($values, $condition); + // We do not check the return value of updateAll() because it's possible + // that the UPDATE statement doesn't change anything and thus returns 0. + $rows = $this->updateAll($values, $condition); - if ($lock !== null && !$rows) { - throw new StaleObjectException('The object being updated is outdated.'); + if ($lock !== null && !$rows) { + throw new StaleObjectException('The object being updated is outdated.'); + } } foreach ($values as $name => $value) { From bd79e1c1be64d611f88825da29b42670302fd5ff Mon Sep 17 00:00:00 2001 From: Paul Klimov Date: Mon, 18 Nov 2013 15:36:06 +0200 Subject: [PATCH 36/59] Sphinx Active Record saving via 'replace' converted into fallback --- extensions/sphinx/ActiveRecord.php | 13 +++++++++++++ tests/unit/extensions/sphinx/ActiveRecordTest.php | 10 ++++++++++ 2 files changed, 23 insertions(+) diff --git a/extensions/sphinx/ActiveRecord.php b/extensions/sphinx/ActiveRecord.php index 5256c1c..c275bb4 100644 --- a/extensions/sphinx/ActiveRecord.php +++ b/extensions/sphinx/ActiveRecord.php @@ -851,7 +851,20 @@ class ActiveRecord extends Model return 0; } + // Replace is supported only by runtime indexes and necessary only for field update + $useReplace = false; + $indexSchema = $this->getIndexSchema(); if ($this->getIndexSchema()->isRuntime) { + foreach ($values as $name => $value) { + $columnSchema = $indexSchema->getColumn($name); + if ($columnSchema->isField) { + $useReplace = true; + break; + } + } + } + + if ($useReplace) { $values = array_merge($values, $this->getOldPrimaryKey(true)); $command = static::getDb()->createCommand(); $command->replace(static::indexName(), $values); diff --git a/tests/unit/extensions/sphinx/ActiveRecordTest.php b/tests/unit/extensions/sphinx/ActiveRecordTest.php index 815ebe8..0bb9dbb 100644 --- a/tests/unit/extensions/sphinx/ActiveRecordTest.php +++ b/tests/unit/extensions/sphinx/ActiveRecordTest.php @@ -161,6 +161,16 @@ class ActiveRecordTest extends SphinxTestCase $record2 = RuntimeIndex::find(['id' => 2]); $this->assertEquals(9, $record2->type_id); + // replace + $query = 'replace'; + $rows = RuntimeIndex::find($query); + $this->assertEmpty($rows); + $record = RuntimeIndex::find(['id' => 2]); + $record->content = 'Test content with ' . $query; + $record->save(); + $rows = RuntimeIndex::find($query); + $this->assertNotEmpty($rows); + // updateAll $pk = ['id' => 2]; $ret = RuntimeIndex::updateAll(['type_id' => 55], $pk); From 01df011ea33838564907b36a380cb95bdc9c82cf Mon Sep 17 00:00:00 2001 From: Paul Klimov Date: Mon, 18 Nov 2013 15:53:45 +0200 Subject: [PATCH 37/59] Sphinx Query updated to implement QueryInterface. --- extensions/sphinx/Query.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/extensions/sphinx/Query.php b/extensions/sphinx/Query.php index fa67ad2..5e16283 100644 --- a/extensions/sphinx/Query.php +++ b/extensions/sphinx/Query.php @@ -11,6 +11,7 @@ use Yii; use yii\base\Component; use yii\base\InvalidCallException; use yii\db\Expression; +use yii\db\QueryInterface; /** * Class Query @@ -18,7 +19,7 @@ use yii\db\Expression; * @author Paul Klimov * @since 2.0 */ -class Query extends Component +class Query extends Component implements QueryInterface { /** * Sort ascending From c355fed2804ebb459c63cec4cee3198d01a94414 Mon Sep 17 00:00:00 2001 From: Paul Klimov Date: Mon, 18 Nov 2013 16:12:59 +0200 Subject: [PATCH 38/59] Sphinx test delta index added --- tests/unit/data/sphinx/sphinx.conf | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/tests/unit/data/sphinx/sphinx.conf b/tests/unit/data/sphinx/sphinx.conf index 6be9606..30cf083 100644 --- a/tests/unit/data/sphinx/sphinx.conf +++ b/tests/unit/data/sphinx/sphinx.conf @@ -44,7 +44,8 @@ source yii2_test_item_src sql_query = \ SELECT *, CURRENT_TIMESTAMP() AS add_date \ - FROM yii2_test_item + FROM yii2_test_item \ + WHERE id <= 100 sql_attr_uint = id sql_attr_uint = category_id @@ -55,6 +56,15 @@ source yii2_test_item_src } +source yii2_test_item_delta_src : yii2_test_item_src +{ + sql_query = \ + SELECT *, CURRENT_TIMESTAMP() AS add_date \ + FROM yii2_test_item \ + WHERE id > 100 +} + + index yii2_test_article_index { source = yii2_test_article_src @@ -73,6 +83,13 @@ index yii2_test_item_index } +index yii2_test_item_delta_index : yii2_test_item_index +{ + source = yii2_test_item_delta_src + path = /var/lib/sphinx/yii2_test_item_delta +} + + index yii2_test_rt_index { type = rt From 6bfc888c8fcbdbedc919bf679dffcd3f7448134c Mon Sep 17 00:00:00 2001 From: Paul Klimov Date: Mon, 18 Nov 2013 16:56:28 +0200 Subject: [PATCH 39/59] Sphinx Active Relation created --- extensions/sphinx/ActiveRelation.php | 22 +++++++++ tests/unit/data/sphinx/ar/ArticleDb.php | 12 +++++ .../unit/extensions/sphinx/ActiveRelationTest.php | 37 +++++--------- .../sphinx/ExternalActiveRelationTest.php | 57 ++++++++++++++++++++++ 4 files changed, 104 insertions(+), 24 deletions(-) create mode 100644 extensions/sphinx/ActiveRelation.php create mode 100644 tests/unit/extensions/sphinx/ExternalActiveRelationTest.php diff --git a/extensions/sphinx/ActiveRelation.php b/extensions/sphinx/ActiveRelation.php new file mode 100644 index 0000000..15a5ba0 --- /dev/null +++ b/extensions/sphinx/ActiveRelation.php @@ -0,0 +1,22 @@ + + * @since 2.0 + */ +class ActiveRelation extends ActiveQuery implements ActiveRelationInterface +{ + use ActiveRelationTrait; +} \ No newline at end of file diff --git a/tests/unit/data/sphinx/ar/ArticleDb.php b/tests/unit/data/sphinx/ar/ArticleDb.php index 3c32f99..4972112 100644 --- a/tests/unit/data/sphinx/ar/ArticleDb.php +++ b/tests/unit/data/sphinx/ar/ArticleDb.php @@ -2,6 +2,7 @@ namespace yiiunit\data\sphinx\ar; +use yii\sphinx\ActiveRelation; use yiiunit\data\ar\ActiveRecord as ActiveRecordDb; class ArticleDb extends ActiveRecordDb @@ -10,4 +11,15 @@ class ArticleDb extends ActiveRecordDb { return 'yii2_test_article'; } + + public function getIndex() + { + $config = [ + 'modelClass' => ArticleIndex::className(), + 'primaryModel' => $this, + 'link' => ['id' => 'id'], + 'multiple' => false, + ]; + return new ActiveRelation($config); + } } \ No newline at end of file diff --git a/tests/unit/extensions/sphinx/ActiveRelationTest.php b/tests/unit/extensions/sphinx/ActiveRelationTest.php index 343a58b..05c48c9 100644 --- a/tests/unit/extensions/sphinx/ActiveRelationTest.php +++ b/tests/unit/extensions/sphinx/ActiveRelationTest.php @@ -19,37 +19,26 @@ class ActiveRelationTest extends SphinxTestCase ActiveRecordDb::$db = $this->getDbConnection(); } + // Tests : + public function testFindLazy() { - /** @var ArticleIndex $article */ - $article = ArticleIndex::find(['id' => 2]); - $this->assertFalse($article->isRelationPopulated('source')); - $source = $article->source; - $this->assertTrue($article->isRelationPopulated('source')); - $this->assertTrue($source instanceof ArticleDb); + /** @var ArticleDb $article */ + $article = ArticleDb::find(['id' => 2]); + $this->assertFalse($article->isRelationPopulated('index')); + $index = $article->index; + $this->assertTrue($article->isRelationPopulated('index')); + $this->assertTrue($index instanceof ArticleIndex); $this->assertEquals(1, count($article->populatedRelations)); } public function testFindEager() { - $articles = ArticleIndex::find()->with('source')->all(); - $this->assertEquals(2, count($articles)); - $this->assertTrue($articles[0]->isRelationPopulated('source')); - $this->assertTrue($articles[1]->isRelationPopulated('source')); - $this->assertTrue($articles[0]->source instanceof ArticleDb); - $this->assertTrue($articles[1]->source instanceof ArticleDb); - } - - /** - * @depends testFindEager - */ - public function testFindWithSnippets() - { - $articles = ArticleIndex::find() - ->match('about') - ->with('source') - ->snippetByModel() - ->all(); + $articles = ArticleDb::find()->with('index')->all(); $this->assertEquals(2, count($articles)); + $this->assertTrue($articles[0]->isRelationPopulated('index')); + $this->assertTrue($articles[1]->isRelationPopulated('index')); + $this->assertTrue($articles[0]->index instanceof ArticleIndex); + $this->assertTrue($articles[1]->index instanceof ArticleIndex); } } \ No newline at end of file diff --git a/tests/unit/extensions/sphinx/ExternalActiveRelationTest.php b/tests/unit/extensions/sphinx/ExternalActiveRelationTest.php new file mode 100644 index 0000000..81f7989 --- /dev/null +++ b/tests/unit/extensions/sphinx/ExternalActiveRelationTest.php @@ -0,0 +1,57 @@ +getConnection(); + ActiveRecordDb::$db = $this->getDbConnection(); + } + + // Tests : + + public function testFindLazy() + { + /** @var ArticleIndex $article */ + $article = ArticleIndex::find(['id' => 2]); + $this->assertFalse($article->isRelationPopulated('source')); + $source = $article->source; + $this->assertTrue($article->isRelationPopulated('source')); + $this->assertTrue($source instanceof ArticleDb); + $this->assertEquals(1, count($article->populatedRelations)); + } + + public function testFindEager() + { + $articles = ArticleIndex::find()->with('source')->all(); + $this->assertEquals(2, count($articles)); + $this->assertTrue($articles[0]->isRelationPopulated('source')); + $this->assertTrue($articles[1]->isRelationPopulated('source')); + $this->assertTrue($articles[0]->source instanceof ArticleDb); + $this->assertTrue($articles[1]->source instanceof ArticleDb); + } + + /** + * @depends testFindEager + */ + public function testFindWithSnippets() + { + $articles = ArticleIndex::find() + ->match('about') + ->with('source') + ->snippetByModel() + ->all(); + $this->assertEquals(2, count($articles)); + } +} \ No newline at end of file From 8bec8c5f5759af69de9d582c210bfed689a9027e Mon Sep 17 00:00:00 2001 From: Paul Klimov Date: Tue, 19 Nov 2013 16:35:20 +0200 Subject: [PATCH 40/59] Sphinx Query Builder updated to respect column types for where statements --- extensions/sphinx/QueryBuilder.php | 222 ++++++++++++++++++------------------- 1 file changed, 108 insertions(+), 114 deletions(-) diff --git a/extensions/sphinx/QueryBuilder.php b/extensions/sphinx/QueryBuilder.php index be2f00f..3097a3d 100644 --- a/extensions/sphinx/QueryBuilder.php +++ b/extensions/sphinx/QueryBuilder.php @@ -62,7 +62,7 @@ class QueryBuilder extends Object $clauses = [ $this->buildSelect($query->select, $query->distinct, $query->selectOption), $this->buildFrom($query->from), - $this->buildWhere($query->where, $params), + $this->buildWhere($query->from, $query->where, $params), $this->buildGroupBy($query->groupBy), $this->buildWithin($query->within), $this->buildOrderBy($query->orderBy), @@ -131,37 +131,16 @@ class QueryBuilder extends Object protected function generateInsertReplace($statement, $index, $columns, &$params) { if (($indexSchema = $this->db->getIndexSchema($index)) !== null) { - $columnSchemas = $indexSchema->columns; + $indexSchemas = [$indexSchema]; } else { - $columnSchemas = []; + $indexSchemas = []; } $names = []; $placeholders = []; foreach ($columns as $name => $value) { $names[] = $this->db->quoteColumnName($name); - if ($value instanceof Expression) { - $placeholders[] = $value->expression; - foreach ($value->params as $n => $v) { - $params[$n] = $v; - } - } else { - if (is_array($value)) { - // MVA : - $placeholderParts = []; - foreach ($value as $subValue) { - $phName = self::PARAM_PREFIX . count($params); - $placeholderParts[] = $phName; - $params[$phName] = isset($columnSchemas[$name]) ? $columnSchemas[$name]->typecast($subValue) : $subValue; - } - $placeholders[] = '(' . implode(',', $placeholderParts) . ')'; - } else { - $phName = self::PARAM_PREFIX . count($params); - $placeholders[] = $phName; - $params[$phName] = isset($columnSchemas[$name]) ? $columnSchemas[$name]->typecast($value) : $value; - } - } + $placeholders[] = $this->composeColumnValue($indexSchemas, $name, $value, $params); } - return $statement . ' INTO ' . $this->db->quoteIndexName($index) . ' (' . implode(', ', $names) . ') VALUES (' . implode(', ', $placeholders) . ')'; @@ -231,9 +210,9 @@ class QueryBuilder extends Object protected function generateBatchInsertReplace($statement, $index, $columns, $rows, &$params) { if (($indexSchema = $this->db->getIndexSchema($index)) !== null) { - $columnSchemas = $indexSchema->columns; + $indexSchemas = [$indexSchema]; } else { - $columnSchemas = []; + $indexSchemas = []; } foreach ($columns as $i => $name) { @@ -244,23 +223,7 @@ class QueryBuilder extends Object foreach ($rows as $row) { $vs = []; foreach ($row as $i => $value) { - if (is_array($value)) { - // MVA : - $vsParts = []; - foreach ($value as $subValue) { - $phName = self::PARAM_PREFIX . count($params); - $vsParts[] = $phName; - $params[$phName] = isset($columnSchemas[$columns[$i]]) ? $columnSchemas[$columns[$i]]->typecast($subValue) : $subValue; - } - $vs[] = '(' . implode(',', $vsParts) . ')'; - } else { - $phName = self::PARAM_PREFIX . count($params); - if (isset($columnSchemas[$columns[$i]])) { - $value = $columnSchemas[$columns[$i]]->typecast($value); - } - $params[$phName] = is_string($value) ? $this->db->quoteValue($value) : $value; - $vs[] = $phName; - } + $vs[] = $this->composeColumnValue($indexSchemas, $columns[$i], $value, $params); } $values[] = '(' . implode(', ', $vs) . ')'; } @@ -292,38 +255,18 @@ class QueryBuilder extends Object public function update($index, $columns, $condition, &$params, $options) { if (($indexSchema = $this->db->getIndexSchema($index)) !== null) { - $columnSchemas = $indexSchema->columns; + $indexSchemas = [$indexSchema]; } else { - $columnSchemas = []; + $indexSchemas = []; } $lines = []; foreach ($columns as $name => $value) { - if ($value instanceof Expression) { - $lines[] = $this->db->quoteColumnName($name) . '=' . $value->expression; - foreach ($value->params as $n => $v) { - $params[$n] = $v; - } - } else { - if (is_array($value)) { - // MVA : - $lineParts = []; - foreach ($value as $subValue) { - $phName = self::PARAM_PREFIX . count($params); - $lineParts[] = $phName; - $params[$phName] = isset($columnSchemas[$name]) ? $columnSchemas[$name]->typecast($subValue) : $subValue; - } - $lines[] = $this->db->quoteColumnName($name) . '=' . '(' . implode(',', $lineParts) . ')'; - } else { - $phName = self::PARAM_PREFIX . count($params); - $lines[] = $this->db->quoteColumnName($name) . '=' . $phName; - $params[$phName] = !is_array($value) && isset($columnSchemas[$name]) ? $columnSchemas[$name]->typecast($value) : $value; - } - } + $lines[] = $this->db->quoteColumnName($name) . '=' . $this->composeColumnValue($indexSchemas, $name, $value, $params); } $sql = 'UPDATE ' . $this->db->quoteIndexName($index) . ' SET ' . implode(', ', $lines); - $where = $this->buildWhere($condition, $params); + $where = $this->buildWhere([$index], $condition, $params); if ($where !== '') { $sql = $sql . ' ' . $where; } @@ -354,7 +297,7 @@ class QueryBuilder extends Object public function delete($index, $condition, &$params) { $sql = 'DELETE FROM ' . $this->db->quoteIndexName($index); - $where = $this->buildWhere($condition, $params); + $where = $this->buildWhere([$index], $condition, $params); return $where === '' ? $sql : $sql . ' ' . $where; } @@ -499,13 +442,26 @@ class QueryBuilder extends Object } /** + * @param string[] $indexes list of index names, which affected by query * @param string|array $condition * @param array $params the binding parameters to be populated * @return string the WHERE clause built from [[query]]. */ - public function buildWhere($condition, &$params) + public function buildWhere($indexes, $condition, &$params) { - $where = $this->buildCondition($condition, $params); + if (empty($condition)) { + return ''; + } + $indexSchemas = []; + if (!empty($indexes)) { + foreach ($indexes as $indexName) { + $index = $this->db->getIndexSchema($indexName); + if ($index !== null) { + $indexSchemas[] = $index; + } + } + } + $where = $this->buildCondition($indexSchemas, $condition, $params); return $where === '' ? '' : 'WHERE ' . $where; } @@ -583,13 +539,14 @@ class QueryBuilder extends Object /** * Parses the condition specification and generates the corresponding SQL expression. + * @param IndexSchema[] $indexes list of indexes, which affected by query * @param string|array $condition the condition specification. Please refer to [[Query::where()]] * on how to specify a condition. * @param array $params the binding parameters to be populated * @return string the generated SQL expression * @throws \yii\db\Exception if the condition is in bad format */ - public function buildCondition($condition, &$params) + public function buildCondition($indexes, $condition, &$params) { static $builders = [ 'AND' => 'buildAndCondition', @@ -614,42 +571,38 @@ class QueryBuilder extends Object if (isset($builders[$operator])) { $method = $builders[$operator]; array_shift($condition); - return $this->$method($operator, $condition, $params); + return $this->$method($indexes, $operator, $condition, $params); } else { throw new Exception('Found unknown operator in query: ' . $operator); } } else { // hash format: 'column1' => 'value1', 'column2' => 'value2', ... - return $this->buildHashCondition($condition, $params); + return $this->buildHashCondition($indexes, $condition, $params); } } /** * Creates a condition based on column-value pairs. + * @param IndexSchema[] $indexes list of indexes, which affected by query * @param array $condition the condition specification. * @param array $params the binding parameters to be populated * @return string the generated SQL expression */ - public function buildHashCondition($condition, &$params) + public function buildHashCondition($indexes, $condition, &$params) { $parts = []; foreach ($condition as $column => $value) { if (is_array($value)) { // IN condition - $parts[] = $this->buildInCondition('IN', [$column, $value], $params); + $parts[] = $this->buildInCondition($indexes, 'IN', [$column, $value], $params); } else { if (strpos($column, '(') === false) { - $column = $this->db->quoteColumnName($column); + $quotedColumn = $this->db->quoteColumnName($column); + } else { + $quotedColumn = $column; } if ($value === null) { - $parts[] = "$column IS NULL"; - } elseif ($value instanceof Expression) { - $parts[] = "$column=" . $value->expression; - foreach ($value->params as $n => $v) { - $params[$n] = $v; - } + $parts[] = "$quotedColumn IS NULL"; } else { - $phName = self::PARAM_PREFIX . count($params); - $parts[] = "$column=$phName"; - $params[$phName] = $value; + $parts[] = $quotedColumn . '=' . $this->composeColumnValue($indexes, $column, $value, $params); } } } @@ -658,17 +611,18 @@ class QueryBuilder extends Object /** * Connects two or more SQL expressions with the `AND` or `OR` operator. + * @param IndexSchema[] $indexes list of indexes, which affected by query * @param string $operator the operator to use for connecting the given operands * @param array $operands the SQL expressions to connect. * @param array $params the binding parameters to be populated * @return string the generated SQL expression */ - public function buildAndCondition($operator, $operands, &$params) + public function buildAndCondition($indexes, $operator, $operands, &$params) { $parts = []; foreach ($operands as $operand) { if (is_array($operand)) { - $operand = $this->buildCondition($operand, $params); + $operand = $this->buildCondition($indexes, $operand, $params); } if ($operand !== '') { $parts[] = $operand; @@ -683,6 +637,7 @@ class QueryBuilder extends Object /** * Creates an SQL expressions with the `BETWEEN` operator. + * @param IndexSchema[] $indexes list of indexes, which affected by query * @param string $operator the operator to use (e.g. `BETWEEN` or `NOT BETWEEN`) * @param array $operands the first operand is the column name. The second and third operands * describe the interval that column value should be in. @@ -690,7 +645,7 @@ class QueryBuilder extends Object * @return string the generated SQL expression * @throws Exception if wrong number of operands have been given. */ - public function buildBetweenCondition($operator, $operands, &$params) + public function buildBetweenCondition($indexes, $operator, $operands, &$params) { if (!isset($operands[0], $operands[1], $operands[2])) { throw new Exception("Operator '$operator' requires three operands."); @@ -699,18 +654,19 @@ class QueryBuilder extends Object list($column, $value1, $value2) = $operands; if (strpos($column, '(') === false) { - $column = $this->db->quoteColumnName($column); + $quotedColumn = $this->db->quoteColumnName($column); + } else { + $quotedColumn = $column; } - $phName1 = self::PARAM_PREFIX . count($params); - $params[$phName1] = $value1; - $phName2 = self::PARAM_PREFIX . count($params); - $params[$phName2] = $value2; + $phName1 = $this->composeColumnValue($indexes, $column, $value1, $params); + $phName2 = $this->composeColumnValue($indexes, $column, $value2, $params); - return "$column $operator $phName1 AND $phName2"; + return "$quotedColumn $operator $phName1 AND $phName2"; } /** * Creates an SQL expressions with the `IN` operator. + * @param IndexSchema[] $indexes list of indexes, which affected by query * @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. @@ -721,7 +677,7 @@ class QueryBuilder extends Object * @return string the generated SQL expression * @throws Exception if wrong number of operands have been given. */ - public function buildInCondition($operator, $operands, &$params) + public function buildInCondition($indexes, $operator, $operands, &$params) { if (!isset($operands[0], $operands[1])) { throw new Exception("Operator '$operator' requires two operands."); @@ -736,7 +692,7 @@ class QueryBuilder extends Object } if (count($column) > 1) { - return $this->buildCompositeInCondition($operator, $column, $values, $params); + return $this->buildCompositeInCondition($indexes, $operator, $column, $values, $params); } elseif (is_array($column)) { $column = reset($column); } @@ -744,18 +700,7 @@ class QueryBuilder extends Object if (is_array($value)) { $value = isset($value[$column]) ? $value[$column] : null; } - if ($value === null) { - $values[$i] = 'NULL'; - } elseif ($value instanceof Expression) { - $values[$i] = $value->expression; - foreach ($value->params as $n => $v) { - $params[$n] = $v; - } - } else { - $phName = self::PARAM_PREFIX . count($params); - $params[$phName] = $value; - $values[$i] = $phName; - } + $values[$i] = $this->composeColumnValue($indexes, $column, $value, $params); } if (strpos($column, '(') === false) { $column = $this->db->quoteColumnName($column); @@ -769,16 +714,22 @@ class QueryBuilder extends Object } } - protected function buildCompositeInCondition($operator, $columns, $values, &$params) + /** + * @param IndexSchema[] $indexes list of indexes, which affected by query + * @param string $operator the operator to use (e.g. `IN` or `NOT IN`) + * @param array $columns + * @param array $values + * @param array $params the binding parameters to be populated + * @return string the generated SQL expression + */ + protected function buildCompositeInCondition($indexes, $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; + $vs[] = $this->composeColumnValue($indexes, $column, $value[$column], $params); } else { $vs[] = 'NULL'; } @@ -795,6 +746,7 @@ class QueryBuilder extends Object /** * Creates an SQL expressions with the `LIKE` operator. + * @param IndexSchema[] $indexes list of indexes, which affected by query * @param string $operator the operator to use (e.g. `LIKE`, `NOT LIKE`, `OR LIKE` or `OR NOT LIKE`) * @param array $operands the first operand is the column name. * The second operand is a single value or an array of values that column value @@ -805,7 +757,7 @@ class QueryBuilder extends Object * @return string the generated SQL expression * @throws Exception if wrong number of operands have been given. */ - public function buildLikeCondition($operator, $operands, &$params) + public function buildLikeCondition($indexes, $operator, $operands, &$params) { if (!isset($operands[0], $operands[1])) { throw new Exception("Operator '$operator' requires two operands."); @@ -902,4 +854,46 @@ class QueryBuilder extends Object } return 'OPTION ' . implode(', ', $optionLines); } + + /** + * Composes column value for SQL, taking in account the column type. + * @param IndexSchema[] $indexes list of indexes, which affected by query + * @param string $columnName name of the column + * @param mixed $value raw column value + * @param array $params the binding parameters to be populated + * @return string SQL expression, which represents column value + */ + protected function composeColumnValue($indexes, $columnName, $value, &$params) { + if ($value === null) { + return 'NULL'; + } elseif ($value instanceof Expression) { + $params = array_merge($params, $value->params); + return $value->expression; + } + foreach ($indexes as $index) { + $columnSchema = $index->getColumn($columnName); + if ($columnSchema !== null) { + break; + } + } + if (is_array($value)) { + // MVA : + $lineParts = []; + foreach ($value as $subValue) { + if ($subValue instanceof Expression) { + $params = array_merge($params, $subValue->params); + $lineParts[] = $subValue->expression; + } else { + $phName = self::PARAM_PREFIX . count($params); + $lineParts[] = $phName; + $params[$phName] = (isset($columnSchema)) ? $columnSchema->typecast($subValue) : $subValue; + } + } + return '(' . implode(',', $lineParts) . ')'; + } else { + $phName = self::PARAM_PREFIX . count($params); + $params[$phName] = (isset($columnSchema)) ? $columnSchema->typecast($value) : $value; + return $phName; + } + } } \ No newline at end of file From 9a45a04365a66a766c32cbe34368d704798a4f4d Mon Sep 17 00:00:00 2001 From: Paul Klimov Date: Tue, 19 Nov 2013 17:18:54 +0200 Subject: [PATCH 41/59] Create relation methods added to Sphinx Active Record. --- extensions/sphinx/ActiveRecord.php | 108 +++++++++++++++++++++++++++++ tests/unit/data/sphinx/ar/ArticleIndex.php | 8 +-- 2 files changed, 109 insertions(+), 7 deletions(-) diff --git a/extensions/sphinx/ActiveRecord.php b/extensions/sphinx/ActiveRecord.php index c275bb4..012faac 100644 --- a/extensions/sphinx/ActiveRecord.php +++ b/extensions/sphinx/ActiveRecord.php @@ -18,6 +18,7 @@ use yii\db\Expression; use yii\db\StaleObjectException; use yii\helpers\Inflector; use yii\helpers\StringHelper; +use Yii; /** * Class ActiveRecord @@ -472,6 +473,113 @@ class ActiveRecord extends Model } /** + * Declares a `has-one` relation. + * The declaration is returned in terms of an [[ActiveRelationInterface]] instance + * through which the related record can be queried and retrieved back. + * + * A `has-one` relation means that there is at most one related record matching + * the criteria set by this relation, e.g., a particular index has one source. + * + * For example, to declare the `source` relation for `ArticleIndex` class, we can write + * the following code in the `ArticleIndex` class: + * + * ~~~ + * public function getSource() + * { + * return $this->hasOne('db', ArticleContent::className(), ['article_id' => 'id']); + * } + * ~~~ + * + * Note that in the above, the 'article_id' key in the `$link` parameter refers to an attribute name + * in the related class `ArticleContent`, while the 'id' value refers to an attribute name + * in the current AR class. + * + * @param string $type relation type or class name. + * - if value contains backslash ("\"), it is treated as full active relation class name, + * for example: "app\mydb\ActiveRelation" + * - if value does not contain backslash ("\"), the active relation class name will be composed + * by pattern: "yii\{type}\ActiveRelation", for example: type "db" refers "yii\db\ActiveRelation", + * type "sphinx" - "yii\sphinx\ActiveRelation" + * @param string $class the class name of the related record + * @param array $link the primary-foreign key constraint. The keys of the array refer to + * the attributes in the `$class` model, while the values of the array refer to the corresponding + * attributes in the index associated with this AR class. + * @return ActiveRelationInterface the relation object. + */ + public function hasOne($type, $class, $link) + { + return $this->createActiveRelation($type, [ + 'modelClass' => $class, + 'primaryModel' => $this, + 'link' => $link, + 'multiple' => false, + ]); + } + + /** + * Declares a `has-many` relation. + * The declaration is returned in terms of an [[ActiveRelationInterface]] instance + * through which the related record can be queried and retrieved back. + * + * A `has-many` relation means that there are multiple related records matching + * the criteria set by this relation, e.g., an article has many tags. + * + * For example, to declare the `tags` relation for `ArticleIndex` class, we can write + * the following code in the `ArticleIndex` class: + * + * ~~~ + * public function getOrders() + * { + * return $this->hasMany('db', Tag::className(), ['id' => 'tag_id']); + * } + * ~~~ + * + * Note that in the above, the 'id' key in the `$link` parameter refers to + * an attribute name in the related class `Tag`, while the 'tag_id' value refers to + * a multi value attribute name in the current AR class. + * + * @param string $type relation type or class name. + * - if value contains backslash ("\"), it is treated as full active relation class name, + * for example: "app\mydb\ActiveRelation" + * - if value does not contain backslash ("\"), the active relation class name will be composed + * by pattern: "yii\{type}\ActiveRelation", for example: type "db" refers "yii\db\ActiveRelation", + * type "sphinx" - "yii\sphinx\ActiveRelation" + * @param string $class the class name of the related record + * @param array $link the primary-foreign key constraint. The keys of the array refer to + * the columns in the table associated with the `$class` model, while the values of the + * array refer to the corresponding columns in the table associated with this AR class. + * @return ActiveRelationInterface the relation object. + */ + public function hasMany($type, $class, $link) + { + return $this->createActiveRelation($type, [ + 'modelClass' => $class, + 'primaryModel' => $this, + 'link' => $link, + 'multiple' => true, + ]); + } + + /** + * Creates an [[ActiveRelationInterface]] instance. + * This method is called by [[hasOne()]] and [[hasMany()]] to create a relation instance. + * You may override this method to return a customized relation. + * @param string $type relation type or class name. + * @param array $config the configuration passed to the ActiveRelation class. + * @return ActiveRelationInterface the newly created [[ActiveRelation]] instance. + */ + protected function createActiveRelation($type, $config = []) + { + if (strpos($type, '\\') === false) { + $class = "yii\\{$type}\\ActiveRelation"; + } else { + $class = $type; + } + $config['class'] = $class; + return Yii::createObject($config); + } + + /** * Populates the named relation with the related records. * Note that this method does not check if the relation exists or not. * @param string $name the relation name (case-sensitive) diff --git a/tests/unit/data/sphinx/ar/ArticleIndex.php b/tests/unit/data/sphinx/ar/ArticleIndex.php index 7c8c079..3865216 100644 --- a/tests/unit/data/sphinx/ar/ArticleIndex.php +++ b/tests/unit/data/sphinx/ar/ArticleIndex.php @@ -20,13 +20,7 @@ class ArticleIndex extends ActiveRecord public function getSource() { - $config = [ - 'modelClass' => ArticleDb::className(), - 'primaryModel' => $this, - 'link' => ['id' => 'id'], - 'multiple' => false, - ]; - return new ActiveRelation($config); + return $this->hasOne('db', ArticleDb::className(), ['id' => 'id']); } public function getSnippetSource() From 6a5b8d190494939b64bc21f6ccc80d591d818698 Mon Sep 17 00:00:00 2001 From: Paul Klimov Date: Wed, 20 Nov 2013 11:27:44 +0200 Subject: [PATCH 42/59] Sphinx Query refactored. --- extensions/sphinx/Query.php | 143 +---------------------------- extensions/sphinx/QueryBuilder.php | 4 +- tests/unit/extensions/sphinx/QueryTest.php | 24 ++--- 3 files changed, 19 insertions(+), 152 deletions(-) diff --git a/extensions/sphinx/Query.php b/extensions/sphinx/Query.php index 5e16283..5961253 100644 --- a/extensions/sphinx/Query.php +++ b/extensions/sphinx/Query.php @@ -12,25 +12,20 @@ use yii\base\Component; use yii\base\InvalidCallException; use yii\db\Expression; use yii\db\QueryInterface; +use yii\db\QueryTrait; /** * Class Query * + * + * Note: implicit LIMIT 0,20 is present by default. + * * @author Paul Klimov * @since 2.0 */ class Query extends Component implements QueryInterface { - /** - * Sort ascending - * @see orderBy - */ - const SORT_ASC = false; - /** - * Sort descending - * @see orderBy - */ - const SORT_DESC = true; + use QueryTrait; /** * @var array the columns being selected. For example, `['id', 'group_id']`. @@ -59,30 +54,6 @@ class Query extends Component implements QueryInterface */ public $match; /** - * @var string|array query condition. This refers to the WHERE clause in a SQL statement. - * For example, `group_id > 5 AND team = 1`. - * @see where() - */ - public $where; - /** - * @var integer maximum number of records to be returned. - * Note: if not set implicit LIMIT 0,20 is present by default. - */ - public $limit; - /** - * @var integer zero-based offset from where the records are to be returned. If not set or - * less than 0, it means starting from the beginning. - * Note: implicit LIMIT 0,20 is present by default. - */ - public $offset; - /** - * @var array how to sort the query results. This is used to construct the ORDER BY clause in a SQL statement. - * The array keys are the columns to be sorted by, and the array values are the corresponding sort directions which - * can be either [[Query::SORT_ASC]] or [[Query::SORT_DESC]]. The array may also contain [[Expression]] objects. - * If that is the case, the expressions will be converted into strings without any change. - */ - public $orderBy; - /** * @var array how to group the query results. For example, `['company', 'department']`. * This is used to construct the GROUP BY clause in a SQL statement. */ @@ -105,12 +76,6 @@ class Query extends Component implements QueryInterface */ public $params; /** - * @var string|callable $column the name of the column by which the query results should be indexed by. - * This can also be a callable (e.g. anonymous function) that returns the index value based on the given - * row data. For more details, see [[indexBy()]]. This property is only used by [[all()]]. - */ - public $indexBy; - /** * @var callback PHP callback, which should be used to fetch source data for the snippets. * Such callback will receive array of query result rows as an argument and must return the * array of snippet source strings in the order, which match one of incoming rows. @@ -183,27 +148,6 @@ class Query extends Component implements QueryInterface } /** - * Sets the [[indexBy]] property. - * @param string|callable $column the name of the column by which the query results should be indexed by. - * This can also be a callable (e.g. anonymous function) that returns the index value based on the given - * row data. The signature of the callable should be: - * - * ~~~ - * function ($row) - * { - * // return the index value corresponding to $row - * } - * ~~~ - * - * @return static the query object itself - */ - public function indexBy($column) - { - $this->indexBy = $column; - return $this; - } - - /** * Executes the query and returns all results as an array. * @param Connection $db the database connection used to generate the SQL statement. * If this parameter is not given, the `db` application component will be used. @@ -571,83 +515,6 @@ class Query extends Component implements QueryInterface } /** - * Sets the ORDER BY part of the query. - * @param string|array $columns the columns (and the directions) to be ordered by. - * Columns can be specified in either a string (e.g. "id ASC, name DESC") or an array - * (e.g. `['id' => Query::SORT_ASC, 'name' => Query::SORT_DESC]`). - * The method will automatically quote the column names unless a column contains some parenthesis - * (which means the column contains a DB expression). - * @return static the query object itself - * @see addOrderBy() - */ - public function orderBy($columns) - { - $this->orderBy = $this->normalizeOrderBy($columns); - return $this; - } - - /** - * Adds additional ORDER BY columns to the query. - * @param string|array $columns the columns (and the directions) to be ordered by. - * Columns can be specified in either a string (e.g. "id ASC, name DESC") or an array - * (e.g. `['id' => Query::SORT_ASC, 'name' => Query::SORT_DESC]`). - * The method will automatically quote the column names unless a column contains some parenthesis - * (which means the column contains a DB expression). - * @return static the query object itself - * @see orderBy() - */ - 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; - } - - 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') ? self::SORT_ASC : self::SORT_DESC; - } else { - $result[$column] = self::SORT_ASC; - } - } - return $result; - } - } - - /** - * Sets the LIMIT part of the query. - * @param integer $limit the limit. Use null or negative value to disable limit. - * @return static the query object itself - */ - public function limit($limit) - { - $this->limit = $limit; - return $this; - } - - /** - * Sets the OFFSET part of the query. - * @param integer $offset the offset. Use null or negative value to disable offset. - * @return static the query object itself - */ - public function offset($offset) - { - $this->offset = $offset; - return $this; - } - - /** * Sets the parameters to be bound to the query. * @param array $params list of query parameter values indexed by parameter placeholders. * For example, `[':name' => 'Dan', ':age' => 31]`. diff --git a/extensions/sphinx/QueryBuilder.php b/extensions/sphinx/QueryBuilder.php index 3097a3d..1369dee 100644 --- a/extensions/sphinx/QueryBuilder.php +++ b/extensions/sphinx/QueryBuilder.php @@ -488,7 +488,7 @@ class QueryBuilder extends Object if (is_object($direction)) { $orders[] = (string)$direction; } else { - $orders[] = $this->db->quoteColumnName($name) . ($direction === Query::SORT_DESC ? ' DESC' : ''); + $orders[] = $this->db->quoteColumnName($name) . ($direction === SORT_DESC ? ' DESC' : ''); } } @@ -806,7 +806,7 @@ class QueryBuilder extends Object if (is_object($direction)) { $orders[] = (string)$direction; } else { - $orders[] = $this->db->quoteColumnName($name) . ($direction === Query::SORT_DESC ? ' DESC' : ''); + $orders[] = $this->db->quoteColumnName($name) . ($direction === SORT_DESC ? ' DESC' : ''); } } return 'WITHIN GROUP ORDER BY ' . implode(', ', $orders); diff --git a/tests/unit/extensions/sphinx/QueryTest.php b/tests/unit/extensions/sphinx/QueryTest.php index f95e35d..59a8595 100644 --- a/tests/unit/extensions/sphinx/QueryTest.php +++ b/tests/unit/extensions/sphinx/QueryTest.php @@ -77,19 +77,19 @@ class QueryTest extends SphinxTestCase { $query = new Query; $query->orderBy('team'); - $this->assertEquals(['team' => false], $query->orderBy); + $this->assertEquals(['team' => SORT_ASC], $query->orderBy); $query->addOrderBy('company'); - $this->assertEquals(['team' => false, 'company' => false], $query->orderBy); + $this->assertEquals(['team' => SORT_ASC, 'company' => SORT_ASC], $query->orderBy); $query->addOrderBy('age'); - $this->assertEquals(['team' => false, 'company' => false, 'age' => false], $query->orderBy); + $this->assertEquals(['team' => SORT_ASC, 'company' => SORT_ASC, 'age' => SORT_ASC], $query->orderBy); - $query->addOrderBy(['age' => true]); - $this->assertEquals(['team' => false, 'company' => false, 'age' => true], $query->orderBy); + $query->addOrderBy(['age' => SORT_DESC]); + $this->assertEquals(['team' => SORT_ASC, 'company' => SORT_ASC, 'age' => SORT_DESC], $query->orderBy); $query->addOrderBy('age ASC, company DESC'); - $this->assertEquals(['team' => false, 'company' => true, 'age' => false], $query->orderBy); + $this->assertEquals(['team' => SORT_ASC, 'company' => SORT_DESC, 'age' => SORT_ASC], $query->orderBy); } public function testLimitOffset() @@ -104,19 +104,19 @@ class QueryTest extends SphinxTestCase { $query = new Query; $query->within('team'); - $this->assertEquals(['team' => false], $query->within); + $this->assertEquals(['team' => SORT_ASC], $query->within); $query->addWithin('company'); - $this->assertEquals(['team' => false, 'company' => false], $query->within); + $this->assertEquals(['team' => SORT_ASC, 'company' => SORT_ASC], $query->within); $query->addWithin('age'); - $this->assertEquals(['team' => false, 'company' => false, 'age' => false], $query->within); + $this->assertEquals(['team' => SORT_ASC, 'company' => SORT_ASC, 'age' => SORT_ASC], $query->within); - $query->addWithin(['age' => true]); - $this->assertEquals(['team' => false, 'company' => false, 'age' => true], $query->within); + $query->addWithin(['age' => SORT_DESC]); + $this->assertEquals(['team' => SORT_ASC, 'company' => SORT_ASC, 'age' => SORT_DESC], $query->within); $query->addWithin('age ASC, company DESC'); - $this->assertEquals(['team' => false, 'company' => true, 'age' => false], $query->within); + $this->assertEquals(['team' => SORT_ASC, 'company' => SORT_DESC, 'age' => SORT_ASC], $query->within); } public function testOptions() From e19c9cebc6dff43a9b69357f66965d92b077b7d8 Mon Sep 17 00:00:00 2001 From: Paul Klimov Date: Wed, 20 Nov 2013 13:14:21 +0200 Subject: [PATCH 43/59] Sphinx has many relation test prepared. --- tests/unit/data/sphinx/ar/ArticleIndex.php | 5 +++++ tests/unit/data/sphinx/ar/TagDb.php | 12 ++++++++++++ tests/unit/data/sphinx/source.sql | 15 ++++++++++++++- .../extensions/sphinx/ExternalActiveRelationTest.php | 17 +++++++++++++++++ 4 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 tests/unit/data/sphinx/ar/TagDb.php diff --git a/tests/unit/data/sphinx/ar/ArticleIndex.php b/tests/unit/data/sphinx/ar/ArticleIndex.php index 3865216..767fdea 100644 --- a/tests/unit/data/sphinx/ar/ArticleIndex.php +++ b/tests/unit/data/sphinx/ar/ArticleIndex.php @@ -23,6 +23,11 @@ class ArticleIndex extends ActiveRecord return $this->hasOne('db', ArticleDb::className(), ['id' => 'id']); } + public function getTags() + { + return $this->hasMany('db', TagDb::className(), ['id' => 'tag']); + } + public function getSnippetSource() { return $this->source->content; diff --git a/tests/unit/data/sphinx/ar/TagDb.php b/tests/unit/data/sphinx/ar/TagDb.php new file mode 100644 index 0000000..2d87eaf --- /dev/null +++ b/tests/unit/data/sphinx/ar/TagDb.php @@ -0,0 +1,12 @@ + 2]); + + // has one : $this->assertFalse($article->isRelationPopulated('source')); $source = $article->source; $this->assertTrue($article->isRelationPopulated('source')); $this->assertTrue($source instanceof ArticleDb); $this->assertEquals(1, count($article->populatedRelations)); + + // has many : + /*$this->assertFalse($article->isRelationPopulated('tags')); + $tags = $article->tags; + $this->assertTrue($article->isRelationPopulated('tags')); + $this->assertEquals(3, count($tags)); + $this->assertTrue($tags[0] instanceof TagDb);*/ } public function testFindEager() { + // has one : $articles = ArticleIndex::find()->with('source')->all(); $this->assertEquals(2, count($articles)); $this->assertTrue($articles[0]->isRelationPopulated('source')); $this->assertTrue($articles[1]->isRelationPopulated('source')); $this->assertTrue($articles[0]->source instanceof ArticleDb); $this->assertTrue($articles[1]->source instanceof ArticleDb); + + // has many : + /*$articles = ArticleIndex::find()->with('tags')->all(); + $this->assertEquals(2, count($articles)); + $this->assertTrue($articles[0]->isRelationPopulated('tags')); + $this->assertTrue($articles[1]->isRelationPopulated('tags'));*/ } /** From d1c87c7e317e62424b7a0d0700898b35c6e1645b Mon Sep 17 00:00:00 2001 From: Paul Klimov Date: Wed, 20 Nov 2013 17:28:02 +0200 Subject: [PATCH 44/59] Sphinx documentation updated. --- extensions/sphinx/ActiveRecord.php | 53 ++++++++++++---------- extensions/sphinx/Command.php | 38 ++++++++++++++-- extensions/sphinx/Connection.php | 52 ++++++++++++++++++++- extensions/sphinx/Query.php | 92 ++++++++++++++++++++++++-------------- extensions/sphinx/QueryBuilder.php | 45 ++++++++++--------- extensions/sphinx/README.md | 11 +++++ extensions/sphinx/Schema.php | 7 +++ extensions/sphinx/composer.json | 4 +- 8 files changed, 218 insertions(+), 84 deletions(-) diff --git a/extensions/sphinx/ActiveRecord.php b/extensions/sphinx/ActiveRecord.php index 012faac..15ef719 100644 --- a/extensions/sphinx/ActiveRecord.php +++ b/extensions/sphinx/ActiveRecord.php @@ -14,14 +14,22 @@ use yii\base\ModelEvent; use yii\base\NotSupportedException; use yii\base\UnknownMethodException; use yii\db\ActiveRelationInterface; -use yii\db\Expression; use yii\db\StaleObjectException; use yii\helpers\Inflector; use yii\helpers\StringHelper; use Yii; /** - * Class ActiveRecord + * ActiveRecord is the base class for classes representing relational data in terms of objects. + * + * @property array $dirtyAttributes The changed attribute values (name-value pairs). This property is + * read-only. + * @property boolean $isNewRecord Whether the record is new and should be inserted when calling [[save()]]. + * @property array $oldAttributes The old attribute values (name-value pairs). + * @property integer $oldPrimaryKey The old primary key value. This property is read-only. + * @property array $populatedRelations An array of relation data indexed by relation names. This property is + * read-only. + * @property integer $primaryKey The primary key value. This property is read-only. * * @author Paul Klimov * @since 2.0 @@ -148,7 +156,7 @@ class ActiveRecord extends Model * Below is an example: * * ~~~ - * $customers = Customer::findBySql('SELECT * FROM tbl_customer')->all(); + * $customers = Article::findBySql("SELECT * FROM `idx_article` WHERE MATCH('development')")->all(); * ~~~ * * @param string $sql the SQL statement to be executed @@ -164,10 +172,10 @@ class ActiveRecord extends Model /** * Updates the whole table using the provided attribute values and conditions. - * For example, to change the status to be 1 for all customers whose status is 2: + * For example, to change the status to be 1 for all articles which status is 2: * * ~~~ - * Customer::updateAll(['status' => 1], 'status = 2'); + * Article::updateAll(['status' => 1], 'status = 2'); * ~~~ * * @param array $attributes attribute values (name-value pairs) to be saved into the table @@ -184,13 +192,12 @@ class ActiveRecord extends Model } /** - * Deletes rows in the table using the provided conditions. - * WARNING: If you do not specify any condition, this method will delete ALL rows in the table. + * Deletes rows in the index using the provided conditions. * - * For example, to delete all customers whose status is 3: + * For example, to delete all articles whose status is 3: * * ~~~ - * Customer::deleteAll('status = 3'); + * Article::deleteAll('status = 3'); * ~~~ * * @param string|array $condition the conditions that will be put in the WHERE part of the DELETE SQL. @@ -208,8 +215,8 @@ class ActiveRecord extends Model /** * Creates an [[ActiveQuery]] instance. * This method is called by [[find()]], [[findBySql()]] and [[count()]] to start a SELECT query. - * You may override this method to return a customized query (e.g. `CustomerQuery` specified - * written for querying `Customer` purpose.) + * You may override this method to return a customized query (e.g. `ArticleQuery` specified + * written for querying `Article` purpose.) * @return ActiveQuery the newly created [[ActiveQuery]] instance. */ public static function createQuery() @@ -218,11 +225,11 @@ class ActiveRecord extends Model } /** - * Declares the name of the database table associated with this AR class. - * By default this method returns the class name as the table name by calling [[Inflector::camel2id()]] - * with prefix 'tbl_'. For example, 'Customer' becomes 'tbl_customer', and 'OrderItem' becomes - * 'tbl_order_item'. You may override this method if the table is not named after this convention. - * @return string the table name + * Declares the name of the Sphinx index associated with this AR class. + * By default this method returns the class name as the index name by calling [[Inflector::camel2id()]]. + * For example, 'Article' becomes 'article', and 'StockItem' becomes + * 'stock_item'. You may override this method if the index is not named after this convention. + * @return string the index name */ public static function indexName() { @@ -230,9 +237,9 @@ class ActiveRecord extends Model } /** - * Returns the schema information of the DB table associated with this AR class. - * @return IndexSchema the schema information of the DB table associated with this AR class. - * @throws InvalidConfigException if the table for the AR class does not exist. + * Returns the schema information of the Sphinx index associated with this AR class. + * @return IndexSchema the schema information of the Sphinx index associated with this AR class. + * @throws InvalidConfigException if the index for the AR class does not exist. */ public static function getIndexSchema() { @@ -246,7 +253,7 @@ class ActiveRecord extends Model /** * Returns the primary key name for this AR class. - * @return string the primary keys of the associated database table. + * @return string the primary key of the associated Sphinx index. */ public static function primaryKey() { @@ -257,15 +264,15 @@ class ActiveRecord extends Model * Builds a snippet from provided data and query, using specified index settings. * @param string|array $source is the source data to extract a snippet from. * It could be either a single string or array of strings. - * @param string $query the full-text query to build snippets for. + * @param string $match the full-text query to build snippets for. * @param array $options list of options in format: optionName => optionValue * @return string|array built snippet in case "source" is a string, list of built snippets * in case "source" is an array. */ - public static function callSnippets($source, $query, $options = []) + public static function callSnippets($source, $match, $options = []) { $command = static::getDb()->createCommand(); - $command->callSnippets(static::indexName(), $source, $query, $options); + $command->callSnippets(static::indexName(), $source, $match, $options); if (is_array($source)) { return $command->queryColumn(); } else { diff --git a/extensions/sphinx/Command.php b/extensions/sphinx/Command.php index 2341d98..2c0b5ed 100644 --- a/extensions/sphinx/Command.php +++ b/extensions/sphinx/Command.php @@ -13,7 +13,37 @@ use yii\caching\Cache; use yii\db\Exception; /** - * Class Command + * Command represents a SQL statement to be executed against a Sphinx. + * + * A command object is usually created by calling [[Connection::createCommand()]]. + * The SQL statement it represents can be set via the [[sql]] property. + * + * To execute a non-query SQL (such as INSERT, REPLACE, DELETE, UPDATE), call [[execute()]]. + * To execute a SQL statement that returns result data set (such as SELECT, CALL SNIPPETS, CALL KEYWORDS), + * use [[queryAll()]], [[queryOne()]], [[queryColumn()]], [[queryScalar()]], or [[query()]]. + * For example, + * + * ~~~ + * $articles = $connection->createCommand("SELECT * FROM `idx_article` WHERE MATCH('programming')")->queryAll(); + * ~~~ + * + * Command supports SQL statement preparation and parameter binding just as [[\yii\db\Command]] does. + * + * Command also supports building SQL statements by providing methods such as [[insert()]], + * [[update()]], etc. For example, + * + * ~~~ + * $connection->createCommand()->update('idx_article', [ + * 'genre_id' => 15, + * 'author_id' => 157, + * ])->execute(); + * ~~~ + * + * To build SELECT SQL statements, please use [[Query]] and [[QueryBuilder]] instead. + * + * @property string $rawSql The raw SQL with parameter values inserted into the corresponding placeholders in + * [[sql]]. This property is read-only. + * @property string $sql The SQL statement to be executed. * * @author Paul Klimov * @since 2.0 @@ -554,14 +584,14 @@ class Command extends Component * @param string $index name of the index, from which to take the text processing settings. * @param string|array $source is the source data to extract a snippet from. * It could be either a single string or array of strings. - * @param string $query the full-text query to build snippets for. + * @param string $match the full-text query to build snippets for. * @param array $options list of options in format: optionName => optionValue * @return static the command object itself */ - public function callSnippets($index, $source, $query, $options = []) + public function callSnippets($index, $source, $match, $options = []) { $params = []; - $sql = $this->db->getQueryBuilder()->callSnippets($index, $source, $query, $options, $params); + $sql = $this->db->getQueryBuilder()->callSnippets($index, $source, $match, $options, $params); return $this->setSql($sql)->bindValues($params); } diff --git a/extensions/sphinx/Connection.php b/extensions/sphinx/Connection.php index 3bb6d63..c0d4562 100644 --- a/extensions/sphinx/Connection.php +++ b/extensions/sphinx/Connection.php @@ -6,14 +6,51 @@ */ namespace yii\sphinx; +use yii\base\NotSupportedException; /** - * Class Connection + * Connection represents the Sphinx connection via MySQL protocol. + * This class uses [PDO](http://www.php.net/manual/en/ref.pdo.php) to maintain such connection. + * Note: although PDO supports numerous database drivers, this class supports only MySQL. + * + * In order to setup Sphinx "searchd" to support MySQL protocol following configuration should be added: + * ``` + * searchd + * { + * listen = localhost:9306:mysql41 + * ... + * } + * ``` + * + * The following example shows how to create a Connection instance and establish + * the Sphinx connection: + * ~~~ + * $connection = new \yii\db\Connection([ + * 'dsn' => 'mysql:host=127.0.0.1;port=9306;', + * 'username' => $username, + * 'password' => $password, + * ]); + * $connection->open(); + * ~~~ + * + * After the Sphinx connection is established, one can execute SQL statements like the following: + * ~~~ + * $command = $connection->createCommand("SELECT * FROM idx_article WHERE MATCH('programming')"); + * $articles = $command->queryAll(); + * $command = $connection->createCommand('UPDATE idx_article SET status=2 WHERE id=1'); + * $command->execute(); + * ~~~ + * + * For more information about how to perform various DB queries, please refer to [[Command]]. + * + * This class supports transactions exactly as "yii\db\Connection". + * + * Note: while this class extends "yii\db\Connection" some of its methods are not supported. * * @property Schema $schema The schema information for this Sphinx connection. This property is read-only. * @property \yii\sphinx\QueryBuilder $queryBuilder The query builder for this Sphinx connection. This property is * read-only. - * @method Schema getSchema() The schema information for this Sphinx connection + * @method \yii\sphinx\Schema getSchema() The schema information for this Sphinx connection * @method \yii\sphinx\QueryBuilder getQueryBuilder() the query builder for this Sphinx connection * * @author Paul Klimov @@ -78,4 +115,15 @@ class Connection extends \yii\db\Connection ]); return $command->bindValues($params); } + + /** + * This method is not supported by Sphinx. + * @param string $sequenceName name of the sequence object + * @return string the row ID of the last row inserted, or the last value retrieved from the sequence object + * @throws \yii\base\NotSupportedException always. + */ + public function getLastInsertID($sequenceName = '') + { + throw new NotSupportedException('"' . $this->className() . '::getLastInsertID" is not supported.'); + } } \ No newline at end of file diff --git a/extensions/sphinx/Query.php b/extensions/sphinx/Query.php index 5961253..4da378e 100644 --- a/extensions/sphinx/Query.php +++ b/extensions/sphinx/Query.php @@ -15,10 +15,31 @@ use yii\db\QueryInterface; use yii\db\QueryTrait; /** - * Class Query + * Query represents a SELECT SQL statement. * + * Query provides a set of methods to facilitate the specification of different clauses + * in a SELECT statement. These methods can be chained together. * - * Note: implicit LIMIT 0,20 is present by default. + * By calling [[createCommand()]], we can get a [[Command]] instance which can be further + * used to perform/execute the Sphinx query. + * + * For example, + * + * ~~~ + * $query = new Query; + * $query->select('id, groupd_id') + * ->from('idx_item') + * ->limit(10); + * // build and execute the query + * $command = $query->createCommand(); + * // $command->sql returns the actual SQL + * $rows = $command->queryAll(); + * ~~~ + * + * Since Sphinx does not store the original indexed text, the snippets for the rows in query result + * should be build separately via another query. You can simplify this workflow using [[snippetCallback]]. + * + * Warning: even if you do not set any query limit, implicit LIMIT 0,20 is present by default! * * @author Paul Klimov * @since 2.0 @@ -43,7 +64,7 @@ class Query extends Component implements QueryInterface */ public $distinct; /** - * @var array the index(es) to be selected from. For example, `['idx_user', 'idx_post']`. + * @var array the index(es) to be selected from. For example, `['idx_user', 'idx_user_delta']`. * This is used to construct the FROM clause in a SQL statement. * @see from() */ @@ -149,8 +170,8 @@ class Query extends Component implements QueryInterface /** * Executes the query and returns all results as an array. - * @param Connection $db the database connection used to generate the SQL statement. - * If this parameter is not given, the `db` application component will be used. + * @param Connection $db the Sphinx connection used to generate the SQL statement. + * If this parameter is not given, the `sphinx` application component will be used. * @return array the query results. If the query results in nothing, an empty array will be returned. */ public function all($db = null) @@ -174,8 +195,8 @@ class Query extends Component implements QueryInterface /** * Executes the query and returns a single row of result. - * @param Connection $db the database connection used to generate the SQL statement. - * If this parameter is not given, the `db` application component will be used. + * @param Connection $db the Sphinx connection used to generate the SQL statement. + * If this parameter is not given, the `sphinx` application component will be used. * @return array|boolean the first row (in terms of an array) of the query result. False is returned if the query * results in nothing. */ @@ -191,8 +212,8 @@ class Query extends Component implements QueryInterface /** * Returns the query result as a scalar value. * The value returned will be the first column in the first row of the query results. - * @param Connection $db the database connection used to generate the SQL statement. - * If this parameter is not given, the `db` application component will be used. + * @param Connection $db the Sphinx connection used to generate the SQL statement. + * If this parameter is not given, the `sphinx` application component will be used. * @return string|boolean the value of the first column in the first row of the query result. * False is returned if the query result is empty. */ @@ -203,8 +224,8 @@ class Query extends Component implements QueryInterface /** * Executes the query and returns the first column of the result. - * @param Connection $db the database connection used to generate the SQL statement. - * If this parameter is not given, the `db` application component will be used. + * @param Connection $db the Sphinx connection used to generate the SQL statement. + * If this parameter is not given, the `sphinx` application component will be used. * @return array the first column of the query result. An empty array is returned if the query results in nothing. */ public function column($db = null) @@ -216,8 +237,8 @@ class Query extends Component implements QueryInterface * Returns the number of records. * @param string $q the COUNT expression. Defaults to '*'. * Make sure you properly quote column names in the expression. - * @param Connection $db the database connection used to generate the SQL statement. - * If this parameter is not given, the `db` application component will be used. + * @param Connection $db the Sphinx connection used to generate the SQL statement. + * If this parameter is not given, the `sphinx` application component will be used. * @return integer number of records */ public function count($q = '*', $db = null) @@ -230,8 +251,8 @@ class Query extends Component implements QueryInterface * Returns the sum of the specified column values. * @param string $q the column name or expression. * Make sure you properly quote column names in the expression. - * @param Connection $db the database connection used to generate the SQL statement. - * If this parameter is not given, the `db` application component will be used. + * @param Connection $db the Sphinx connection used to generate the SQL statement. + * If this parameter is not given, the `sphinx` application component will be used. * @return integer the sum of the specified column values */ public function sum($q, $db = null) @@ -244,8 +265,8 @@ class Query extends Component implements QueryInterface * Returns the average of the specified column values. * @param string $q the column name or expression. * Make sure you properly quote column names in the expression. - * @param Connection $db the database connection used to generate the SQL statement. - * If this parameter is not given, the `db` application component will be used. + * @param Connection $db the Sphinx connection used to generate the SQL statement. + * If this parameter is not given, the `sphinx` application component will be used. * @return integer the average of the specified column values. */ public function average($q, $db = null) @@ -258,8 +279,8 @@ class Query extends Component implements QueryInterface * Returns the minimum of the specified column values. * @param string $q the column name or expression. * Make sure you properly quote column names in the expression. - * @param Connection $db the database connection used to generate the SQL statement. - * If this parameter is not given, the `db` application component will be used. + * @param Connection $db the Sphinx connection used to generate the SQL statement. + * If this parameter is not given, the `sphinx` application component will be used. * @return integer the minimum of the specified column values. */ public function min($q, $db = null) @@ -272,8 +293,8 @@ class Query extends Component implements QueryInterface * Returns the maximum of the specified column values. * @param string $q the column name or expression. * Make sure you properly quote column names in the expression. - * @param Connection $db the database connection used to generate the SQL statement. - * If this parameter is not given, the `db` application component will be used. + * @param Connection $db the Sphinx connection used to generate the SQL statement. + * If this parameter is not given, the `sphinx` application component will be used. * @return integer the maximum of the specified column values. */ public function max($q, $db = null) @@ -284,8 +305,8 @@ class Query extends Component implements QueryInterface /** * Returns a value indicating whether the query result contains any row of data. - * @param Connection $db the database connection used to generate the SQL statement. - * If this parameter is not given, the `db` application component will be used. + * @param Connection $db the Sphinx connection used to generate the SQL statement. + * If this parameter is not given, the `sphinx` application component will be used. * @return boolean whether the query result contains any row of data. */ public function exists($db = null) @@ -298,11 +319,9 @@ class Query extends Component implements QueryInterface * Sets the SELECT part of the query. * @param string|array $columns the columns to be selected. * Columns can be specified in either a string (e.g. "id, name") or an array (e.g. ['id', 'name']). - * Columns can contain table prefixes (e.g. "tbl_user.id") and/or column aliases (e.g. "tbl_user.id AS user_id"). * The method will automatically quote the column names unless a column contains some parenthesis - * (which means the column contains a DB expression). - * @param string $option additional option that should be appended to the 'SELECT' keyword. For example, - * in MySQL, the option 'SQL_CALC_FOUND_ROWS' can be used. + * (which means the column contains a Sphinx expression). + * @param string $option additional option that should be appended to the 'SELECT' keyword. * @return static the query object itself */ public function select($columns, $option = null) @@ -328,11 +347,10 @@ class Query extends Component implements QueryInterface /** * Sets the FROM part of the query. - * @param string|array $tables the table(s) to be selected from. This can be either a string (e.g. `'tbl_user'`) - * or an array (e.g. `['tbl_user', 'tbl_profile']`) specifying one or several table names. - * Table names can contain schema prefixes (e.g. `'public.tbl_user'`) and/or table aliases (e.g. `'tbl_user u'`). + * @param string|array $tables the table(s) to be selected from. This can be either a string (e.g. `'idx_user'`) + * or an array (e.g. `['idx_user', 'idx_user_delta']`) specifying one or several index names. * The method will automatically quote the table names unless it contains some parenthesis - * (which means the table is given as a sub-query or DB expression). + * (which means the table is given as a sub-query or Sphinx expression). * @return static the query object itself */ public function from($tables) @@ -618,8 +636,11 @@ class Query extends Component implements QueryInterface } /** - * @param callback $callback + * Sets the PHP callback, which should be used to retrieve the source data + * for the snippets building. + * @param callback $callback PHP callback, which should be used to fetch source data for the snippets. * @return static the query object itself + * @see snippetCallback */ public function snippetCallback($callback) { @@ -628,8 +649,10 @@ class Query extends Component implements QueryInterface } /** - * @param array $options + * Sets the call snippets query options. + * @param array $options call snippet options in format: option_name => option_value * @return static the query object itself + * @see snippetCallback */ public function snippetOptions($options) { @@ -661,7 +684,8 @@ class Query extends Component implements QueryInterface /** * Builds a snippets from provided source data. * @param array $source the source data to extract a snippet from. - * @return array snippets list + * @throws InvalidCallException in case [[match]] is not specified. + * @return array snippets list. */ protected function callSnippets(array $source) { diff --git a/extensions/sphinx/QueryBuilder.php b/extensions/sphinx/QueryBuilder.php index 1369dee..498fb92 100644 --- a/extensions/sphinx/QueryBuilder.php +++ b/extensions/sphinx/QueryBuilder.php @@ -12,7 +12,10 @@ use yii\db\Exception; use yii\db\Expression; /** - * Class QueryBuilder + * QueryBuilder builds a SELECT SQL statement based on the specification given as a [[Query]] object. + * + * QueryBuilder can also be used to build SQL statements such as INSERT, REPLACE, UPDATE, DELETE, + * from a [[Query]] object. * * @author Paul Klimov * @since 2.0 @@ -22,7 +25,7 @@ class QueryBuilder extends Object /** * The prefix for automatically generated query binding parameters. */ - const PARAM_PREFIX = ':sp'; + const PARAM_PREFIX = ':qp'; /** * @var Connection the Sphinx connection. @@ -80,6 +83,7 @@ class QueryBuilder extends Object * $sql = $queryBuilder->insert('idx_user', [ * 'name' => 'Sam', * 'age' => 30, + * 'id' => 10, * ], $params); * ~~~ * @@ -104,6 +108,7 @@ class QueryBuilder extends Object * $sql = $queryBuilder->replace('idx_user', [ * 'name' => 'Sam', * 'age' => 30, + * 'id' => 10, * ], $params); * ~~~ * @@ -151,10 +156,10 @@ class QueryBuilder extends Object * For example, * * ~~~ - * $connection->createCommand()->batchInsert('idx_user', ['name', 'age'], [ - * ['Tom', 30], - * ['Jane', 20], - * ['Linda', 25], + * $connection->createCommand()->batchInsert('idx_user', ['id', 'name', 'age'], [ + * [1, 'Tom', 30], + * [2, 'Jane', 20], + * [3, 'Linda', 25], * ])->execute(); * ~~~ * @@ -164,7 +169,7 @@ class QueryBuilder extends Object * @param array $columns the column names * @param array $rows the rows to be batch inserted into the index * @param array $params the binding parameters that will be generated by this method. - * They should be bound to the DB command later. + * They should be bound to the Sphinx command later. * @return string the batch INSERT SQL statement */ public function batchInsert($index, $columns, $rows, &$params) @@ -177,10 +182,10 @@ class QueryBuilder extends Object * For example, * * ~~~ - * $connection->createCommand()->batchReplace('idx_user', ['name', 'age'], [ - * ['Tom', 30], - * ['Jane', 20], - * ['Linda', 25], + * $connection->createCommand()->batchReplace('idx_user', ['id', 'name', 'age'], [ + * [1, 'Tom', 30], + * [2, 'Jane', 20], + * [3, 'Linda', 25], * ])->execute(); * ~~~ * @@ -190,7 +195,7 @@ class QueryBuilder extends Object * @param array $columns the column names * @param array $rows the rows to be batch replaced in the index * @param array $params the binding parameters that will be generated by this method. - * They should be bound to the DB command later. + * They should be bound to the Sphinx command later. * @return string the batch INSERT SQL statement */ public function batchReplace($index, $columns, $rows, &$params) @@ -248,7 +253,7 @@ class QueryBuilder extends Object * @param array|string $condition the condition that will be put in the WHERE part. Please * refer to [[Query::where()]] on how to specify condition. * @param array $params the binding parameters that will be modified by this method - * so that they can be bound to the DB command later. + * so that they can be bound to the Sphinx command later. * @param array $options list of options in format: optionName => optionValue * @return string the UPDATE SQL */ @@ -282,7 +287,7 @@ class QueryBuilder extends Object * For example, * * ~~~ - * $sql = $queryBuilder->delete('tbl_user', 'status = 0'); + * $sql = $queryBuilder->delete('idx_user', 'status = 0'); * ~~~ * * The method will properly escape the index and column names. @@ -291,7 +296,7 @@ class QueryBuilder extends Object * @param array|string $condition the condition that will be put in the WHERE part. Please * refer to [[Query::where()]] on how to specify condition. * @param array $params the binding parameters that will be modified by this method - * so that they can be bound to the DB command later. + * so that they can be bound to the Sphinx command later. * @return string the DELETE SQL */ public function delete($index, $condition, &$params) @@ -316,13 +321,13 @@ class QueryBuilder extends Object * @param string $index name of the index, from which to take the text processing settings. * @param string|array $source is the source data to extract a snippet from. * It could be either a single string or array of strings. - * @param string $query the full-text query to build snippets for. + * @param string $match the full-text query to build snippets for. * @param array $options list of options in format: optionName => optionValue * @param array $params the binding parameters that will be modified by this method * so that they can be bound to the Sphinx command later. * @return string the SQL statement for call snippets. */ - public function callSnippets($index, $source, $query, $options, &$params) + public function callSnippets($index, $source, $match, $options, &$params) { if (is_array($source)) { $dataSqlParts = []; @@ -339,8 +344,8 @@ class QueryBuilder extends Object } $indexParamName = self::PARAM_PREFIX . count($params); $params[$indexParamName] = $index; - $queryParamName = self::PARAM_PREFIX . count($params); - $params[$queryParamName] = $query; + $matchParamName = self::PARAM_PREFIX . count($params); + $params[$matchParamName] = $match; if (!empty($options)) { $optionParts = []; foreach ($options as $name => $value) { @@ -356,7 +361,7 @@ class QueryBuilder extends Object } else { $optionSql = ''; } - return 'CALL SNIPPETS(' . $dataSql. ', ' . $indexParamName . ', ' . $queryParamName . $optionSql. ')'; + return 'CALL SNIPPETS(' . $dataSql. ', ' . $indexParamName . ', ' . $matchParamName . $optionSql. ')'; } /** diff --git a/extensions/sphinx/README.md b/extensions/sphinx/README.md index fad7613..e135318 100644 --- a/extensions/sphinx/README.md +++ b/extensions/sphinx/README.md @@ -39,4 +39,15 @@ Usage & Documentation This extension adds [Sphinx](http://sphinxsearch.com/docs) full text search engine extension for the Yii framework. This extension interact with Sphinx search daemon using MySQL protocol and [SphinxQL](http://sphinxsearch.com/docs/current.html#sphinxql) query language. +In order to setup Sphinx "searchd" to support MySQL protocol following configuration should be added: +``` +searchd +{ + listen = localhost:9306:mysql41 + ... +} +``` +This extension supports all Sphinx features including [Runtime Indexes](http://sphinxsearch.com/docs/current.html#rt-indexes). +Since this extension uses MySQL protocol to access Sphinx, it shares base approach and much code from the +regular "yii\db" package. \ No newline at end of file diff --git a/extensions/sphinx/Schema.php b/extensions/sphinx/Schema.php index 856f07f..242752c 100644 --- a/extensions/sphinx/Schema.php +++ b/extensions/sphinx/Schema.php @@ -15,6 +15,13 @@ use yii\caching\GroupDependency; /** * Schema represents the Sphinx schema information. * + * @property QueryBuilder $queryBuilder The query builder for this connection. This property is read-only. + * @property string[] $indexNames All index names in the Sphinx. This property is read-only. + * @property string[] $indexTypes ALL index types in the Sphinx (index name => index type). + * This property is read-only. + * @property IndexSchema[] $tableSchemas The metadata for all indexes in the Sphinx. Each array element is an + * instance of [[IndexSchema]] or its child class. This property is read-only. + * * @author Paul Klimov * @since 2.0 */ diff --git a/extensions/sphinx/composer.json b/extensions/sphinx/composer.json index a42358e..0331667 100644 --- a/extensions/sphinx/composer.json +++ b/extensions/sphinx/composer.json @@ -19,7 +19,9 @@ ], "minimum-stability": "dev", "require": { - "yiisoft/yii2": "*" + "yiisoft/yii2": "*", + "ext-pdo": "*", + "ext-pdo_mysql": "*" }, "autoload": { "psr-0": { "yii\\sphinx\\": "" } From bb46d0594e82fe4a2f9682705be822b71cc56e33 Mon Sep 17 00:00:00 2001 From: Klimov Paul Date: Wed, 20 Nov 2013 20:24:56 +0200 Subject: [PATCH 45/59] Sphinx documentation updated. --- extensions/sphinx/ActiveQuery.php | 42 ++++++++++++++++- extensions/sphinx/ActiveRecord.php | 88 +++++++++++++++++++----------------- extensions/sphinx/ActiveRelation.php | 2 +- extensions/sphinx/Connection.php | 4 +- extensions/sphinx/Query.php | 4 +- extensions/sphinx/README.md | 18 +++++++- 6 files changed, 109 insertions(+), 49 deletions(-) diff --git a/extensions/sphinx/ActiveQuery.php b/extensions/sphinx/ActiveQuery.php index aaaf419..a196b56 100644 --- a/extensions/sphinx/ActiveQuery.php +++ b/extensions/sphinx/ActiveQuery.php @@ -11,7 +11,47 @@ use yii\db\ActiveQueryInterface; use yii\db\ActiveQueryTrait; /** - * Class ActiveQuery + * ActiveQuery represents a Sphinx query associated with an Active Record class. + * + * ActiveQuery instances are usually created by [[ActiveRecord::find()]] and [[ActiveRecord::findBySql()]]. + * + * Because ActiveQuery extends from [[Query]], one can use query methods, such as [[where()]], + * [[orderBy()]] to customize the query options. + * + * ActiveQuery also provides the following additional query options: + * + * - [[with()]]: list of relations that this query should be performed with. + * - [[indexBy()]]: the name of the column by which the query result should be indexed. + * - [[asArray()]]: whether to return each record as an array. + * + * These options can be configured using methods of the same name. For example: + * + * ~~~ + * $articles = Article::find()->with('source')->asArray()->all(); + * ~~~ + * + * ActiveQuery allows to build the snippets using sources provided by ActiveRecord. + * You can use [[snippetByModel()]] method to enable this. + * For example: + * + * ~~~ + * class Article extends ActiveRecord + * { + * public function getSource() + * { + * return $this->hasOne('db', ArticleDb::className(), ['id' => 'id']); + * } + * + * public function getSnippetSource() + * { + * return $this->source->content; + * } + * + * ... + * } + * + * $articles = Article::find()->with('source')->snippetByModel()->all(); + * ~~~ * * @author Paul Klimov * @since 2.0 diff --git a/extensions/sphinx/ActiveRecord.php b/extensions/sphinx/ActiveRecord.php index 15ef719..8364395 100644 --- a/extensions/sphinx/ActiveRecord.php +++ b/extensions/sphinx/ActiveRecord.php @@ -30,6 +30,7 @@ use Yii; * @property array $populatedRelations An array of relation data indexed by relation names. This property is * read-only. * @property integer $primaryKey The primary key value. This property is read-only. + * @property string $snippet current snippet value for this Active Record instance.. * * @author Paul Klimov * @since 2.0 @@ -103,7 +104,9 @@ class ActiveRecord extends Model */ private $_related = []; /** - * @var string snippet value for this Active Record instance. + * @var string current snippet value for this Active Record instance. + * It will be filled up automatically when instance found using [[Query::snippetCallback]] + * or [[ActiveQuery::snippetByModel()]]. */ private $_snippet; @@ -302,27 +305,28 @@ class ActiveRecord extends Model } /** - * @param string $query snippet source query + * Returns current snippet value or generates new one from given match. + * @param string $match snippet source query * @param array $options list of options in format: optionName => optionValue * @return string snippet value */ - public function getSnippet($query = null, $options = []) + public function getSnippet($match = null, $options = []) { - if ($query !== null) { - $this->_snippet = $this->fetchSnippet($query, $options); + if ($match !== null) { + $this->_snippet = $this->fetchSnippet($match, $options); } return $this->_snippet; } /** * Builds up the snippet value from the given query. - * @param string $query the full-text query to build snippets for. + * @param string $match the full-text query to build snippets for. * @param array $options list of options in format: optionName => optionValue * @return string snippet value. */ - protected function fetchSnippet($query, $options = []) + protected function fetchSnippet($match, $options = []) { - return static::callSnippets($this->getSnippetSource(), $query, $options); + return static::callSnippets($this->getSnippetSource(), $match, $options); } /** @@ -330,12 +334,12 @@ class ActiveRecord extends Model * Active Record instance. * Child classes must implement this method to return the actual snippet source text. * For example: - * ```php + * ~~~ * public function getSnippetSource() * { * return $this->snippetSourceRelation->content; * } - * ``` + * ~~~ * @return string snippet source string. * @throws \yii\base\NotSupportedException if this is not supported by the Active Record class */ @@ -364,6 +368,9 @@ class ActiveRecord extends Model * and implement necessary business logic (e.g. merging the changes, prompting stated data) * to resolve the conflict. * + * Warning: optimistic lock will NOT work in case of updating fields (not attributes) for the + * runtime indexes! + * * @return string the column name that stores the lock version of a table row. * If null is returned (default implemented), optimistic locking will not be supported. */ @@ -373,10 +380,10 @@ class ActiveRecord extends Model } /** - * Declares which DB operations should be performed within a transaction in different scenarios. + * Declares which operations should be performed within a transaction in different scenarios. * The supported DB operations are: [[OP_INSERT]], [[OP_UPDATE]] and [[OP_DELETE]], * which correspond to the [[insert()]], [[update()]] and [[delete()]] methods, respectively. - * By default, these methods are NOT enclosed in a DB transaction. + * By default, these methods are NOT enclosed in a transaction. * * In some scenarios, to ensure data consistency, you may want to enclose some or all of them * in transactions. You can do so by overriding this method and returning the operations @@ -763,20 +770,21 @@ class ActiveRecord extends Model * This method will call [[insert()]] when [[isNewRecord]] is true, or [[update()]] * when [[isNewRecord]] is false. * - * For example, to save a customer record: + * For example, to save an article record: * * ~~~ - * $customer = new Customer; // or $customer = Customer::find($id); - * $customer->name = $name; - * $customer->email = $email; + * $customer = new Article; // or $customer = Article::find(['id' => $id]); + * $customer->id = $id; + * $customer->genre_id = $genreId; + * $customer->content = $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. + * If the validation fails, the record will not be saved. * @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. + * meaning all attributes that are loaded from index will be saved. * @return boolean whether the saving succeeds */ public function save($runValidation = true, $attributes = null) @@ -789,7 +797,7 @@ class ActiveRecord extends Model } /** - * Inserts a row into the associated database table using the attribute values of this record. + * Inserts a row into the associated Sphinx index using the attribute values of this record. * * This method performs the following steps in order: * @@ -798,31 +806,29 @@ class ActiveRecord extends Model * 2. call [[afterValidate()]] when `$runValidation` is true. * 3. call [[beforeSave()]]. If the method returns false, it will skip the * rest of the steps; - * 4. insert the record into database. If this fails, it will skip the rest of the steps; + * 4. insert the record into index. If this fails, it will skip the rest of the steps; * 5. call [[afterSave()]]; * * In the above step 1, 2, 3 and 5, events [[EVENT_BEFORE_VALIDATE]], * [[EVENT_BEFORE_INSERT]], [[EVENT_AFTER_INSERT]] and [[EVENT_AFTER_VALIDATE]] * will be raised by the corresponding methods. * - * Only the [[changedAttributes|changed attribute values]] will be inserted into database. - * - * If the table's primary key is auto-incremental and is null during insertion, - * it will be populated with the actual value after insertion. + * Only the [[changedAttributes|changed attribute values]] will be inserted. * - * For example, to insert a customer record: + * For example, to insert an article record: * * ~~~ - * $customer = new Customer; - * $customer->name = $name; - * $customer->email = $email; - * $customer->insert(); + * $article = new Article; + * $article->id = $id; + * $article->genre_id = $genreId; + * $article->content = $content; + * $article->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. + * If the validation fails, the record will not be inserted. * @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. + * meaning all attributes that are loaded from index will be saved. * @return boolean whether the attributes are valid and the record is inserted successfully. * @throws \Exception in case insert failed. */ @@ -877,7 +883,7 @@ class ActiveRecord extends Model } /** - * Saves the changes to this active record into the associated database table. + * Saves the changes to this active record into the associated Sphinx index. * * This method performs the following steps in order: * @@ -886,7 +892,7 @@ class ActiveRecord extends Model * 2. call [[afterValidate()]] when `$runValidation` is true. * 3. call [[beforeSave()]]. If the method returns false, it will skip the * rest of the steps; - * 4. save the record into database. If this fails, it will skip the rest of the steps; + * 4. save the record into index. If this fails, it will skip the rest of the steps; * 5. call [[afterSave()]]; * * In the above step 1, 2, 3 and 5, events [[EVENT_BEFORE_VALIDATE]], @@ -895,13 +901,13 @@ class ActiveRecord extends Model * * Only the [[changedAttributes|changed attribute values]] will be saved into database. * - * For example, to update a customer record: + * For example, to update an article record: * * ~~~ - * $customer = Customer::find($id); - * $customer->name = $name; - * $customer->email = $email; - * $customer->update(); + * $article = Article::find(['id' => $id]); + * $article->genre_id = $genreId; + * $article->group_id = $groupId; + * $article->update(); * ~~~ * * Note that it is possible the update does not affect any row in the table. @@ -1012,13 +1018,13 @@ class ActiveRecord extends Model } /** - * Deletes the table row corresponding to this active record. + * Deletes the index entry corresponding to this active record. * * This method performs the following steps in order: * * 1. call [[beforeDelete()]]. If the method returns false, it will skip the * rest of the steps; - * 2. delete the record from the database; + * 2. delete the record from the index; * 3. call [[afterDelete()]]. * * In the above step 1 and 3, events named [[EVENT_BEFORE_DELETE]] and [[EVENT_AFTER_DELETE]] @@ -1298,8 +1304,6 @@ class ActiveRecord extends Model * This method is called by [[create()]]. * You may override this method if the instance being created * depends on the row data to be populated into the record. - * For example, by creating a record based on the value of a column, - * you may implement the so-called single-table inheritance mapping. * @param array $row row data to be populated into the record. * @return ActiveRecord the newly created active record */ diff --git a/extensions/sphinx/ActiveRelation.php b/extensions/sphinx/ActiveRelation.php index 15a5ba0..c0dd0ca 100644 --- a/extensions/sphinx/ActiveRelation.php +++ b/extensions/sphinx/ActiveRelation.php @@ -11,7 +11,7 @@ use yii\db\ActiveRelationInterface; use yii\db\ActiveRelationTrait; /** - * Class ActiveRelation + * ActiveRelation represents a relation to Sphinx Active Record class. * * @author Paul Klimov * @since 2.0 diff --git a/extensions/sphinx/Connection.php b/extensions/sphinx/Connection.php index c0d4562..dbbe27a 100644 --- a/extensions/sphinx/Connection.php +++ b/extensions/sphinx/Connection.php @@ -14,13 +14,13 @@ use yii\base\NotSupportedException; * Note: although PDO supports numerous database drivers, this class supports only MySQL. * * In order to setup Sphinx "searchd" to support MySQL protocol following configuration should be added: - * ``` + * ~~~ * searchd * { * listen = localhost:9306:mysql41 * ... * } - * ``` + * ~~~ * * The following example shows how to create a Connection instance and establish * the Sphinx connection: diff --git a/extensions/sphinx/Query.php b/extensions/sphinx/Query.php index 4da378e..ff0dcba 100644 --- a/extensions/sphinx/Query.php +++ b/extensions/sphinx/Query.php @@ -101,7 +101,7 @@ class Query extends Component implements QueryInterface * Such callback will receive array of query result rows as an argument and must return the * array of snippet source strings in the order, which match one of incoming rows. * For example: - * ```php + * ~~~ * $query = new Query; * $query->from('idx_item') * ->match('pencil') @@ -113,7 +113,7 @@ class Query extends Component implements QueryInterface * return $result; * }) * ->all(); - * ``` + * ~~~ */ public $snippetCallback; /** diff --git a/extensions/sphinx/README.md b/extensions/sphinx/README.md index e135318..35c400a 100644 --- a/extensions/sphinx/README.md +++ b/extensions/sphinx/README.md @@ -50,4 +50,20 @@ searchd This extension supports all Sphinx features including [Runtime Indexes](http://sphinxsearch.com/docs/current.html#rt-indexes). Since this extension uses MySQL protocol to access Sphinx, it shares base approach and much code from the -regular "yii\db" package. \ No newline at end of file +regular "yii\db" package. + +To use this extension, simply add the following code in your application configuration: + +```php +return [ + //.... + 'components' => [ + 'sphinx' => [ + 'class' => 'yii\sphinx\Connection', + 'dsn' => 'mysql:host=127.0.0.1;port=9306;', + 'username' => '', + 'password' => '', + ], + ], +]; +``` \ No newline at end of file From 765d47d00d5425f42320f5f3bf73aaeb481df2cd Mon Sep 17 00:00:00 2001 From: Paul Klimov Date: Thu, 21 Nov 2013 13:41:35 +0200 Subject: [PATCH 46/59] Sphinx Active Record updated to be compatible with ActiveDataProvider. --- extensions/sphinx/ActiveRecord.php | 40 +++++++++++++-------- extensions/sphinx/QueryBuilder.php | 2 +- .../extensions/sphinx/ActiveDataProviderTest.php | 42 ++++++++++++++++++++++ 3 files changed, 69 insertions(+), 15 deletions(-) create mode 100644 tests/unit/extensions/sphinx/ActiveDataProviderTest.php diff --git a/extensions/sphinx/ActiveRecord.php b/extensions/sphinx/ActiveRecord.php index 15ef719..a5f8c71 100644 --- a/extensions/sphinx/ActiveRecord.php +++ b/extensions/sphinx/ActiveRecord.php @@ -253,11 +253,16 @@ class ActiveRecord extends Model /** * Returns the primary key name for this AR class. - * @return string the primary key of the associated Sphinx index. + * The default implementation will return the primary key as declared + * in the Sphinx index, which is associated with this AR class. + * + * Note that an array should be returned even for a table with single primary key. + * + * @return string[] the primary keys of the associated Sphinx index. */ public static function primaryKey() { - return static::getIndexSchema()->primaryKey; + return [static::getIndexSchema()->primaryKey]; } /** @@ -861,8 +866,9 @@ class ActiveRecord extends Model } $values = $this->getDirtyAttributes($attributes); if (empty($values)) { - $key = $this->primaryKey(); - $values[$key] = isset($this->_attributes[$key]) ? $this->_attributes[$key] : null; + foreach ($this->primaryKey() as $key) { + $values[$key] = isset($this->_attributes[$key]) ? $this->_attributes[$key] : null; + } } $db = static::getDb(); $command = $db->createCommand()->insert($this->indexName(), $values); @@ -1231,12 +1237,15 @@ class ActiveRecord extends Model */ public function getPrimaryKey($asArray = false) { - $key = $this->primaryKey(); - $value = isset($this->_attributes[$key]) ? $this->_attributes[$key] : null; - if ($asArray) { - return [$key => $value]; + $keys = $this->primaryKey(); + if (count($keys) === 1 && !$asArray) { + return isset($this->_attributes[$keys[0]]) ? $this->_attributes[$keys[0]] : null; } else { - return $value; + $values = []; + foreach ($keys as $name) { + $values[$name] = isset($this->_attributes[$name]) ? $this->_attributes[$name] : null; + } + return $values; } } @@ -1254,12 +1263,15 @@ class ActiveRecord extends Model */ public function getOldPrimaryKey($asArray = false) { - $key = $this->primaryKey(); - $value = isset($this->_oldAttributes[$key]) ? $this->_oldAttributes[$key] : null; - if ($asArray) { - return [$key => $value]; + $keys = $this->primaryKey(); + if (count($keys) === 1 && !$asArray) { + return isset($this->_oldAttributes[$keys[0]]) ? $this->_oldAttributes[$keys[0]] : null; } else { - return $value; + $values = []; + foreach ($keys as $name) { + $values[$name] = isset($this->_oldAttributes[$name]) ? $this->_oldAttributes[$name] : null; + } + return $values; } } diff --git a/extensions/sphinx/QueryBuilder.php b/extensions/sphinx/QueryBuilder.php index 498fb92..e21e620 100644 --- a/extensions/sphinx/QueryBuilder.php +++ b/extensions/sphinx/QueryBuilder.php @@ -493,7 +493,7 @@ class QueryBuilder extends Object if (is_object($direction)) { $orders[] = (string)$direction; } else { - $orders[] = $this->db->quoteColumnName($name) . ($direction === SORT_DESC ? ' DESC' : ''); + $orders[] = $this->db->quoteColumnName($name) . ($direction === SORT_DESC ? ' DESC' : 'ASC'); } } diff --git a/tests/unit/extensions/sphinx/ActiveDataProviderTest.php b/tests/unit/extensions/sphinx/ActiveDataProviderTest.php new file mode 100644 index 0000000..17a0970 --- /dev/null +++ b/tests/unit/extensions/sphinx/ActiveDataProviderTest.php @@ -0,0 +1,42 @@ +getConnection(); + } + + // Tests : + + public function testActiveQuery() + { + $provider = new ActiveDataProvider([ + 'query' => ArticleIndex::find()->orderBy('id ASC'), + ]); + $models = $provider->getModels(); + $this->assertEquals(2, count($models)); + $this->assertTrue($models[0] instanceof ArticleIndex); + $this->assertTrue($models[1] instanceof ArticleIndex); + $this->assertEquals([1, 2], $provider->getKeys()); + + $provider = new ActiveDataProvider([ + 'query' => ArticleIndex::find(), + 'pagination' => [ + 'pageSize' => 1, + ] + ]); + $models = $provider->getModels(); + $this->assertEquals(1, count($models)); + } +} \ No newline at end of file From c1b5946d4048b41b6d5019c2a0841d7f37c148ae Mon Sep 17 00:00:00 2001 From: Paul Klimov Date: Fri, 22 Nov 2013 13:26:42 +0200 Subject: [PATCH 47/59] yii\sphinx\Command reworked to extend yii\db\Command. yii\sphinx\DataReader removed. --- extensions/sphinx/Command.php | 554 +++++++-------------------- extensions/sphinx/DataReader.php | 265 ------------- tests/unit/extensions/sphinx/CommandTest.php | 3 +- 3 files changed, 134 insertions(+), 688 deletions(-) delete mode 100644 extensions/sphinx/DataReader.php diff --git a/extensions/sphinx/Command.php b/extensions/sphinx/Command.php index 2c0b5ed..93c02f8 100644 --- a/extensions/sphinx/Command.php +++ b/extensions/sphinx/Command.php @@ -8,9 +8,7 @@ namespace yii\sphinx; use Yii; -use yii\base\Component; -use yii\caching\Cache; -use yii\db\Exception; +use yii\base\NotSupportedException; /** * Command represents a SQL statement to be executed against a Sphinx. @@ -41,406 +39,14 @@ use yii\db\Exception; * * To build SELECT SQL statements, please use [[Query]] and [[QueryBuilder]] instead. * - * @property string $rawSql The raw SQL with parameter values inserted into the corresponding placeholders in - * [[sql]]. This property is read-only. - * @property string $sql The SQL statement to be executed. + * @property \yii\sphinx\Connection $db the Sphinx connection that this command is associated with. * * @author Paul Klimov * @since 2.0 */ -class Command extends Component +class Command extends \yii\db\Command { /** - * @var Connection the Sphinx connection that this command is associated with - */ - public $db; - /** - * @var \PDOStatement the PDOStatement object that this command is associated with - */ - public $pdoStatement; - /** - * @var integer the default fetch mode for this command. - * @see http://www.php.net/manual/en/function.PDOStatement-setFetchMode.php - */ - public $fetchMode = \PDO::FETCH_ASSOC; - /** - * @var array the parameters (name => value) that are bound to the current PDO statement. - * This property is maintained by methods such as [[bindValue()]]. - * Do not modify it directly. - */ - public $params = []; - /** - * @var string the SphinxQL statement that this command represents - */ - private $_sql; - - /** - * Returns the SQL statement for this command. - * @return string the SQL statement to be executed - */ - public function getSql() - { - return $this->_sql; - } - - /** - * Specifies the SQL statement to be executed. - * The previous SQL execution (if any) will be cancelled, and [[params]] will be cleared as well. - * @param string $sql the SQL statement to be set. - * @return static this command instance - */ - public function setSql($sql) - { - if ($sql !== $this->_sql) { - $this->cancel(); - $this->_sql = $this->db->quoteSql($sql); - $this->params = []; - } - return $this; - } - - /** - * Returns the raw SQL by inserting parameter values into the corresponding placeholders in [[sql]]. - * Note that the return value of this method should mainly be used for logging purpose. - * It is likely that this method returns an invalid SQL due to improper replacement of parameter placeholders. - * @return string the raw SQL with parameter values inserted into the corresponding placeholders in [[sql]]. - */ - public function getRawSql() - { - if (empty($this->params)) { - return $this->_sql; - } else { - $params = []; - foreach ($this->params as $name => $value) { - if (is_string($value)) { - $params[$name] = $this->db->quoteValue($value); - } elseif ($value === null) { - $params[$name] = 'NULL'; - } else { - $params[$name] = $value; - } - } - if (isset($params[1])) { - $sql = ''; - foreach (explode('?', $this->_sql) as $i => $part) { - $sql .= (isset($params[$i]) ? $params[$i] : '') . $part; - } - return $sql; - } else { - return strtr($this->_sql, $params); - } - } - } - - /** - * Prepares the SQL statement to be executed. - * For complex SQL statement that is to be executed multiple times, - * this may improve performance. - * For SQL statement with binding parameters, this method is invoked - * automatically. - * @throws Exception if there is any DB error - */ - public function prepare() - { - if ($this->pdoStatement == null) { - $sql = $this->getSql(); - try { - $this->pdoStatement = $this->db->pdo->prepare($sql); - } catch (\Exception $e) { - $message = $e->getMessage() . "\nFailed to prepare SQL: $sql"; - $errorInfo = $e instanceof \PDOException ? $e->errorInfo : null; - throw new Exception($message, $errorInfo, (int)$e->getCode(), $e); - } - } - } - - /** - * Cancels the execution of the SQL statement. - * This method mainly sets [[pdoStatement]] to be null. - */ - public function cancel() - { - $this->pdoStatement = null; - } - - /** - * Binds a parameter to the SQL statement to be executed. - * @param string|integer $name parameter identifier. For a prepared statement - * using named placeholders, this will be a parameter name of - * the form `:name`. For a prepared statement using question mark - * placeholders, this will be the 1-indexed position of the parameter. - * @param mixed $value Name of the PHP variable to bind to the SQL statement parameter - * @param integer $dataType SQL data type of the parameter. If null, the type is determined by the PHP type of the value. - * @param integer $length length of the data type - * @param mixed $driverOptions the driver-specific options - * @return static the current command being executed - * @see http://www.php.net/manual/en/function.PDOStatement-bindParam.php - */ - public function bindParam($name, &$value, $dataType = null, $length = null, $driverOptions = null) - { - $this->prepare(); - if ($dataType === null) { - $dataType = $this->db->getSchema()->getPdoType($value); - } - if ($length === null) { - $this->pdoStatement->bindParam($name, $value, $dataType); - } elseif ($driverOptions === null) { - $this->pdoStatement->bindParam($name, $value, $dataType, $length); - } else { - $this->pdoStatement->bindParam($name, $value, $dataType, $length, $driverOptions); - } - $this->params[$name] =& $value; - return $this; - } - - /** - * Binds a value to a parameter. - * @param string|integer $name Parameter identifier. For a prepared statement - * using named placeholders, this will be a parameter name of - * the form `:name`. For a prepared statement using question mark - * placeholders, this will be the 1-indexed position of the parameter. - * @param mixed $value The value to bind to the parameter - * @param integer $dataType SQL data type of the parameter. If null, the type is determined by the PHP type of the value. - * @return static the current command being executed - * @see http://www.php.net/manual/en/function.PDOStatement-bindValue.php - */ - public function bindValue($name, $value, $dataType = null) - { - $this->prepare(); - if ($dataType === null) { - $dataType = $this->db->getSchema()->getPdoType($value); - } - $this->pdoStatement->bindValue($name, $value, $dataType); - $this->params[$name] = $value; - return $this; - } - - /** - * Binds a list of values to the corresponding parameters. - * This is similar to [[bindValue()]] except that it binds multiple values at a time. - * Note that the SQL data type of each value is determined by its PHP type. - * @param array $values the values to be bound. This must be given in terms of an associative - * array with array keys being the parameter names, and array values the corresponding parameter values, - * e.g. `[':name' => 'John', ':age' => 25]`. By default, the PDO type of each value is determined - * by its PHP type. You may explicitly specify the PDO type by using an array: `[value, type]`, - * e.g. `[':name' => 'John', ':profile' => [$profile, \PDO::PARAM_LOB]]`. - * @return static the current command being executed - */ - public function bindValues($values) - { - if (!empty($values)) { - $this->prepare(); - foreach ($values as $name => $value) { - if (is_array($value)) { - $type = $value[1]; - $value = $value[0]; - } else { - $type = $this->db->getSchema()->getPdoType($value); - } - $this->pdoStatement->bindValue($name, $value, $type); - $this->params[$name] = $value; - } - } - return $this; - } - - /** - * Executes the SQL statement. - * This method should only be used for executing non-query SQL statement, such as `INSERT`, `DELETE`, `UPDATE` SQLs. - * No result set will be returned. - * @return integer number of rows affected by the execution. - * @throws Exception execution failed - */ - public function execute() - { - $sql = $this->getSql(); - - $rawSql = $this->getRawSql(); - - Yii::trace($rawSql, __METHOD__); - - if ($sql == '') { - return 0; - } - - $token = $rawSql; - try { - Yii::beginProfile($token, __METHOD__); - - $this->prepare(); - $this->pdoStatement->execute(); - $n = $this->pdoStatement->rowCount(); - - Yii::endProfile($token, __METHOD__); - return $n; - } catch (\Exception $e) { - Yii::endProfile($token, __METHOD__); - $message = $e->getMessage() . "\nThe SQL being executed was: $rawSql"; - $errorInfo = $e instanceof \PDOException ? $e->errorInfo : null; - throw new Exception($message, $errorInfo, (int)$e->getCode(), $e); - } - } - - /** - * Executes the SQL statement and returns query result. - * This method is for executing a SQL query that returns result set, such as `SELECT`. - * @return DataReader the reader object for fetching the query result - * @throws Exception execution failed - */ - public function query() - { - return $this->queryInternal(''); - } - - /** - * Executes the SQL statement and returns ALL rows at once. - * @param integer $fetchMode the result fetch mode. Please refer to [PHP manual](http://www.php.net/manual/en/function.PDOStatement-setFetchMode.php) - * for valid fetch modes. If this parameter is null, the value set in [[fetchMode]] will be used. - * @return array all rows of the query result. Each array element is an array representing a row of data. - * An empty array is returned if the query results in nothing. - * @throws Exception execution failed - */ - public function queryAll($fetchMode = null) - { - return $this->queryInternal('fetchAll', $fetchMode); - } - - /** - * Executes the SQL statement and returns the first row of the result. - * This method is best used when only the first row of result is needed for a query. - * @param integer $fetchMode the result fetch mode. Please refer to [PHP manual](http://www.php.net/manual/en/function.PDOStatement-setFetchMode.php) - * for valid fetch modes. If this parameter is null, the value set in [[fetchMode]] will be used. - * @return array|boolean the first row (in terms of an array) of the query result. False is returned if the query - * results in nothing. - * @throws Exception execution failed - */ - public function queryOne($fetchMode = null) - { - return $this->queryInternal('fetch', $fetchMode); - } - - /** - * Executes the SQL statement and returns the value of the first column in the first row of data. - * This method is best used when only a single value is needed for a query. - * @return string|boolean the value of the first column in the first row of the query result. - * False is returned if there is no value. - * @throws Exception execution failed - */ - public function queryScalar() - { - $result = $this->queryInternal('fetchColumn', 0); - if (is_resource($result) && get_resource_type($result) === 'stream') { - return stream_get_contents($result); - } else { - return $result; - } - } - - /** - * Executes the SQL statement and returns the first column of the result. - * This method is best used when only the first column of result (i.e. the first element in each row) - * is needed for a query. - * @return array the first column of the query result. Empty array is returned if the query results in nothing. - * @throws Exception execution failed - */ - public function queryColumn() - { - return $this->queryInternal('fetchAll', \PDO::FETCH_COLUMN); - } - - /** - * Performs the actual DB query of a SQL statement. - * @param string $method method of PDOStatement to be called - * @param integer $fetchMode the result fetch mode. Please refer to [PHP manual](http://www.php.net/manual/en/function.PDOStatement-setFetchMode.php) - * for valid fetch modes. If this parameter is null, the value set in [[fetchMode]] will be used. - * @return mixed the method execution result - * @throws Exception if the query causes any problem - */ - private function queryInternal($method, $fetchMode = null) - { - $db = $this->db; - $rawSql = $this->getRawSql(); - - Yii::trace($rawSql, __METHOD__); - - /** @var $cache \yii\caching\Cache */ - if ($db->enableQueryCache && $method !== '') { - $cache = is_string($db->queryCache) ? Yii::$app->getComponent($db->queryCache) : $db->queryCache; - } - - if (isset($cache) && $cache instanceof Cache) { - $cacheKey = [ - __CLASS__, - $db->dsn, - $db->username, - $rawSql, - ]; - if (($result = $cache->get($cacheKey)) !== false) { - Yii::trace('Query result served from cache', __METHOD__); - return $result; - } - } - - $token = $rawSql; - try { - Yii::beginProfile($token, __METHOD__); - - $this->prepare(); - $this->pdoStatement->execute(); - - if ($method === '') { - $result = new DataReader($this); - } else { - if ($fetchMode === null) { - $fetchMode = $this->fetchMode; - } - $result = call_user_func_array([$this->pdoStatement, $method], (array)$fetchMode); - $this->pdoStatement->closeCursor(); - } - - Yii::endProfile($token, __METHOD__); - - if (isset($cache, $cacheKey) && $cache instanceof Cache) { - $cache->set($cacheKey, $result, $db->queryCacheDuration, $db->queryCacheDependency); - Yii::trace('Saved query result in cache', __METHOD__); - } - - return $result; - } catch (\Exception $e) { - Yii::endProfile($token, __METHOD__); - $message = $e->getMessage() . "\nThe SQL being executed was: $rawSql"; - $errorInfo = $e instanceof \PDOException ? $e->errorInfo : null; - throw new Exception($message, $errorInfo, (int)$e->getCode(), $e); - } - } - - /** - * Creates an INSERT command. - * For example, - * - * ~~~ - * $connection->createCommand()->insert('idx_user', [ - * 'name' => 'Sam', - * 'age' => 30, - * ])->execute(); - * ~~~ - * - * The method will properly escape the column names, and bind the values to be inserted. - * - * Note that the created command is not executed until [[execute()]] is called. - * - * @param string $index the index that new rows will be inserted into. - * @param array $columns the column data (name => value) to be inserted into the index. - * @return static the command object itself - */ - public function insert($index, $columns) - { - $params = []; - $sql = $this->db->getQueryBuilder()->insert($index, $columns, $params); - return $this->setSql($sql)->bindValues($params); - } - - /** * Creates a batch INSERT command. * For example, * @@ -545,30 +151,6 @@ class Command extends Component } /** - * Creates a DELETE command. - * For example, - * - * ~~~ - * $connection->createCommand()->delete('tbl_user', 'status = 0')->execute(); - * ~~~ - * - * The method will properly escape the index and column names. - * - * Note that the created command is not executed until [[execute()]] is called. - * - * @param string $index the index where the data will be deleted from. - * @param string|array $condition the condition that will be put in the WHERE part. Please - * refer to [[Query::where()]] on how to specify condition. - * @param array $params the parameters to be bound to the command - * @return static the command object itself - */ - public function delete($index, $condition = '', $params = []) - { - $sql = $this->db->getQueryBuilder()->delete($index, $condition, $params); - return $this->setSql($sql)->bindValues($params); - } - - /** * Creates a SQL command for truncating a runtime index. * @param string $index the index to be truncated. The name will be properly quoted by the method. * @return static the command object itself @@ -608,4 +190,134 @@ class Command extends Component $sql = $this->db->getQueryBuilder()->callKeywords($index, $text, $fetchStatistic, $params); return $this->setSql($sql)->bindValues($params); } + + // Not Supported : + + /** + * @inheritdoc + */ + public function createTable($table, $columns, $options = null) + { + throw new NotSupportedException('"' . __METHOD__ . '" is not supported.'); + } + + /** + * @inheritdoc + */ + public function renameTable($table, $newName) + { + throw new NotSupportedException('"' . __METHOD__ . '" is not supported.'); + } + + /** + * @inheritdoc + */ + public function dropTable($table) + { + throw new NotSupportedException('"' . __METHOD__ . '" is not supported.'); + } + + /** + * @inheritdoc + */ + public function truncateTable($table) + { + throw new NotSupportedException('"' . __METHOD__ . '" is not supported.'); + } + + /** + * @inheritdoc + */ + public function addColumn($table, $column, $type) + { + throw new NotSupportedException('"' . __METHOD__ . '" is not supported.'); + } + + /** + * @inheritdoc + */ + public function dropColumn($table, $column) + { + throw new NotSupportedException('"' . __METHOD__ . '" is not supported.'); + } + + /** + * @inheritdoc + */ + public function renameColumn($table, $oldName, $newName) + { + throw new NotSupportedException('"' . __METHOD__ . '" is not supported.'); + } + + /** + * @inheritdoc + */ + public function alterColumn($table, $column, $type) + { + throw new NotSupportedException('"' . __METHOD__ . '" is not supported.'); + } + + /** + * @inheritdoc + */ + public function addPrimaryKey($name, $table, $columns) + { + throw new NotSupportedException('"' . __METHOD__ . '" is not supported.'); + } + + /** + * @inheritdoc + */ + public function dropPrimaryKey($name, $table) + { + throw new NotSupportedException('"' . __METHOD__ . '" is not supported.'); + } + + /** + * @inheritdoc + */ + public function addForeignKey($name, $table, $columns, $refTable, $refColumns, $delete = null, $update = null) + { + throw new NotSupportedException('"' . __METHOD__ . '" is not supported.'); + } + + /** + * @inheritdoc + */ + public function dropForeignKey($name, $table) + { + throw new NotSupportedException('"' . __METHOD__ . '" is not supported.'); + } + + /** + * @inheritdoc + */ + public function createIndex($name, $table, $columns, $unique = false) + { + throw new NotSupportedException('"' . __METHOD__ . '" is not supported.'); + } + + /** + * @inheritdoc + */ + public function dropIndex($name, $table) + { + throw new NotSupportedException('"' . __METHOD__ . '" is not supported.'); + } + + /** + * @inheritdoc + */ + public function resetSequence($table, $value = null) + { + throw new NotSupportedException('"' . __METHOD__ . '" is not supported.'); + } + + /** + * @inheritdoc + */ + public function checkIntegrity($check = true, $schema = '') + { + throw new NotSupportedException('"' . __METHOD__ . '" is not supported.'); + } } \ No newline at end of file diff --git a/extensions/sphinx/DataReader.php b/extensions/sphinx/DataReader.php deleted file mode 100644 index 4b8ffe5..0000000 --- a/extensions/sphinx/DataReader.php +++ /dev/null @@ -1,265 +0,0 @@ -query('SELECT * FROM idx_post'); - * - * while ($row = $reader->read()) { - * $rows[] = $row; - * } - * - * // equivalent to: - * foreach ($reader as $row) { - * $rows[] = $row; - * } - * - * // equivalent to: - * $rows = $reader->readAll(); - * ~~~ - * - * Note that since DataReader is a forward-only stream, you can only traverse it once. - * Doing it the second time will throw an exception. - * - * It is possible to use a specific mode of data fetching by setting - * [[fetchMode]]. See the [PHP manual](http://www.php.net/manual/en/function.PDOStatement-setFetchMode.php) - * for more details about possible fetch mode. - * - * @property integer $columnCount The number of columns in the result set. This property is read-only. - * @property integer $fetchMode Fetch mode. This property is write-only. - * @property boolean $isClosed Whether the reader is closed or not. This property is read-only. - * @property integer $rowCount Number of rows contained in the result. This property is read-only. - * - * @author Qiang Xue - * @since 2.0 - */ -class DataReader extends Object implements \Iterator, \Countable -{ - /** - * @var \PDOStatement the PDOStatement associated with the command - */ - private $_statement; - private $_closed = false; - private $_row; - private $_index = -1; - - /** - * Constructor. - * @param Command $command the command generating the query result - * @param array $config name-value pairs that will be used to initialize the object properties - */ - public function __construct(Command $command, $config = []) - { - $this->_statement = $command->pdoStatement; - $this->_statement->setFetchMode(\PDO::FETCH_ASSOC); - parent::__construct($config); - } - - /** - * Binds a column to a PHP variable. - * When rows of data are being fetched, the corresponding column value - * will be set in the variable. Note, the fetch mode must include PDO::FETCH_BOUND. - * @param integer|string $column Number of the column (1-indexed) or name of the column - * in the result set. If using the column name, be aware that the name - * should match the case of the column, as returned by the driver. - * @param mixed $value Name of the PHP variable to which the column will be bound. - * @param integer $dataType Data type of the parameter - * @see http://www.php.net/manual/en/function.PDOStatement-bindColumn.php - */ - public function bindColumn($column, &$value, $dataType = null) - { - if ($dataType === null) { - $this->_statement->bindColumn($column, $value); - } else { - $this->_statement->bindColumn($column, $value, $dataType); - } - } - - /** - * Set the default fetch mode for this statement - * @param integer $mode fetch mode - * @see http://www.php.net/manual/en/function.PDOStatement-setFetchMode.php - */ - public function setFetchMode($mode) - { - $params = func_get_args(); - call_user_func_array([$this->_statement, 'setFetchMode'], $params); - } - - /** - * Advances the reader to the next row in a result set. - * @return array the current row, false if no more row available - */ - public function read() - { - return $this->_statement->fetch(); - } - - /** - * Returns a single column from the next row of a result set. - * @param integer $columnIndex zero-based column index - * @return mixed the column of the current row, false if no more rows available - */ - public function readColumn($columnIndex) - { - return $this->_statement->fetchColumn($columnIndex); - } - - /** - * Returns an object populated with the next row of data. - * @param string $className class name of the object to be created and populated - * @param array $fields Elements of this array are passed to the constructor - * @return mixed the populated object, false if no more row of data available - */ - public function readObject($className, $fields) - { - return $this->_statement->fetchObject($className, $fields); - } - - /** - * Reads the whole result set into an array. - * @return array the result set (each array element represents a row of data). - * An empty array will be returned if the result contains no row. - */ - public function readAll() - { - return $this->_statement->fetchAll(); - } - - /** - * Advances the reader to the next result when reading the results of a batch of statements. - * This method is only useful when there are multiple result sets - * returned by the query. Not all DBMS support this feature. - * @return boolean Returns true on success or false on failure. - */ - public function nextResult() - { - if (($result = $this->_statement->nextRowset()) !== false) { - $this->_index = -1; - } - return $result; - } - - /** - * Closes the reader. - * This frees up the resources allocated for executing this SQL statement. - * Read attempts after this method call are unpredictable. - */ - public function close() - { - $this->_statement->closeCursor(); - $this->_closed = true; - } - - /** - * whether the reader is closed or not. - * @return boolean whether the reader is closed or not. - */ - public function getIsClosed() - { - return $this->_closed; - } - - /** - * Returns the number of rows in the result set. - * Note, most DBMS may not give a meaningful count. - * In this case, use "SELECT COUNT(*) FROM tableName" to obtain the number of rows. - * @return integer number of rows contained in the result. - */ - public function getRowCount() - { - return $this->_statement->rowCount(); - } - - /** - * Returns the number of rows in the result set. - * This method is required by the Countable interface. - * Note, most DBMS may not give a meaningful count. - * In this case, use "SELECT COUNT(*) FROM tableName" to obtain the number of rows. - * @return integer number of rows contained in the result. - */ - public function count() - { - return $this->getRowCount(); - } - - /** - * Returns the number of columns in the result set. - * Note, even there's no row in the reader, this still gives correct column number. - * @return integer the number of columns in the result set. - */ - public function getColumnCount() - { - return $this->_statement->columnCount(); - } - - /** - * Resets the iterator to the initial state. - * This method is required by the interface Iterator. - * @throws InvalidCallException if this method is invoked twice - */ - public function rewind() - { - if ($this->_index < 0) { - $this->_row = $this->_statement->fetch(); - $this->_index = 0; - } else { - throw new InvalidCallException('DataReader cannot rewind. It is a forward-only reader.'); - } - } - - /** - * Returns the index of the current row. - * This method is required by the interface Iterator. - * @return integer the index of the current row. - */ - public function key() - { - return $this->_index; - } - - /** - * Returns the current row. - * This method is required by the interface Iterator. - * @return mixed the current row. - */ - public function current() - { - return $this->_row; - } - - /** - * Moves the internal pointer to the next row. - * This method is required by the interface Iterator. - */ - public function next() - { - $this->_row = $this->_statement->fetch(); - $this->_index++; - } - - /** - * Returns whether there is a row of data at current position. - * This method is required by the interface Iterator. - * @return boolean whether there is a row of data at current position. - */ - public function valid() - { - return $this->_row !== false; - } -} \ No newline at end of file diff --git a/tests/unit/extensions/sphinx/CommandTest.php b/tests/unit/extensions/sphinx/CommandTest.php index e83fad2..b2346ef 100644 --- a/tests/unit/extensions/sphinx/CommandTest.php +++ b/tests/unit/extensions/sphinx/CommandTest.php @@ -2,8 +2,7 @@ namespace yiiunit\extensions\sphinx; -use yii\sphinx\DataReader; -use yii\db\Expression; +use yii\db\DataReader; /** * @group sphinx From 01c269a912261929a30bb32035daabc2e534488b Mon Sep 17 00:00:00 2001 From: Paul Klimov Date: Fri, 22 Nov 2013 13:37:43 +0200 Subject: [PATCH 48/59] Redundant typecast removed from yii\sphinx\ActiveRecord::create() --- extensions/sphinx/ActiveRecord.php | 3 --- extensions/sphinx/Connection.php | 2 +- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/extensions/sphinx/ActiveRecord.php b/extensions/sphinx/ActiveRecord.php index 95106a6..e62620c 100644 --- a/extensions/sphinx/ActiveRecord.php +++ b/extensions/sphinx/ActiveRecord.php @@ -1297,9 +1297,6 @@ class ActiveRecord extends Model $column = $columns[$name]; if ($column->isMva) { $value = explode(',', $value); - $value = array_map([$column, 'typecast'], $value); - } else { - $value = $column->typecast($value); } $record->_attributes[$name] = $value; } else { diff --git a/extensions/sphinx/Connection.php b/extensions/sphinx/Connection.php index dbbe27a..a43b0c2 100644 --- a/extensions/sphinx/Connection.php +++ b/extensions/sphinx/Connection.php @@ -124,6 +124,6 @@ class Connection extends \yii\db\Connection */ public function getLastInsertID($sequenceName = '') { - throw new NotSupportedException('"' . $this->className() . '::getLastInsertID" is not supported.'); + throw new NotSupportedException('"' . __METHOD__ . '" is not supported.'); } } \ No newline at end of file From 400b5310cae8fd6431570bc1083e5cd839ac7b5c Mon Sep 17 00:00:00 2001 From: Paul Klimov Date: Fri, 22 Nov 2013 14:06:32 +0200 Subject: [PATCH 49/59] Docs for Sphinx extension updated. --- extensions/sphinx/ActiveRecord.php | 2 +- extensions/sphinx/README.md | 49 ++++++++++++++++++++++ .../extensions/sphinx/ActiveDataProviderTest.php | 24 +++++++++++ 3 files changed, 74 insertions(+), 1 deletion(-) diff --git a/extensions/sphinx/ActiveRecord.php b/extensions/sphinx/ActiveRecord.php index e62620c..a59c065 100644 --- a/extensions/sphinx/ActiveRecord.php +++ b/extensions/sphinx/ActiveRecord.php @@ -35,7 +35,7 @@ use Yii; * @author Paul Klimov * @since 2.0 */ -class ActiveRecord extends Model +abstract class ActiveRecord extends Model { /** * @event Event an event that is triggered when the record is initialized via [[init()]]. diff --git a/extensions/sphinx/README.md b/extensions/sphinx/README.md index 35c400a..ae7c285 100644 --- a/extensions/sphinx/README.md +++ b/extensions/sphinx/README.md @@ -66,4 +66,53 @@ return [ ], ], ]; +``` + +This extension provides ActiveRecord solution similar ot the [[\yii\db\ActiveRecord]]. +To declare an ActiveRecord class you need to extend [[\yii\sphinx\ActiveRecord]] and +implement the `indexName` method: + +```php +use yii\sphinx\ActiveRecord; + +class Article extends ActiveRecord +{ + /** + * @return string the name of the index associated with this ActiveRecord class. + */ + public static function indexName() + { + return 'idx_article'; + } +} +``` + +You can use [[\yii\data\ActiveDataProvider]] with the [[\yii\sphinx\Query]] and [[\yii\sphinx\ActiveQuery]]: + +```php +use yii\data\ActiveDataProvider; +use yii\sphinx\Query; + +$query = new Query; +$query->from('yii2_test_article_index')->match('development'); +$provider = new ActiveDataProvider([ + 'query' => $query, + 'pagination' => [ + 'pageSize' => 10, + ] +]); +$models = $provider->getModels(); +``` + +```php +use yii\data\ActiveDataProvider; +use app\models\Article; + +$provider = new ActiveDataProvider([ + 'query' => Article::find(), + 'pagination' => [ + 'pageSize' => 10, + ] +]); +$models = $provider->getModels(); ``` \ No newline at end of file diff --git a/tests/unit/extensions/sphinx/ActiveDataProviderTest.php b/tests/unit/extensions/sphinx/ActiveDataProviderTest.php index 17a0970..6a81900 100644 --- a/tests/unit/extensions/sphinx/ActiveDataProviderTest.php +++ b/tests/unit/extensions/sphinx/ActiveDataProviderTest.php @@ -3,6 +3,7 @@ namespace yiiunit\extensions\sphinx; use yii\data\ActiveDataProvider; +use yii\sphinx\Query; use yiiunit\data\sphinx\ar\ActiveRecord; use yiiunit\data\sphinx\ar\ArticleIndex; @@ -19,6 +20,29 @@ class ActiveDataProviderTest extends SphinxTestCase // Tests : + public function testQuery() + { + $query = new Query; + $query->from('yii2_test_article_index'); + + $provider = new ActiveDataProvider([ + 'query' => $query, + 'db' => $this->getConnection(), + ]); + $models = $provider->getModels(); + $this->assertEquals(2, count($models)); + + $provider = new ActiveDataProvider([ + 'query' => $query, + 'db' => $this->getConnection(), + 'pagination' => [ + 'pageSize' => 1, + ] + ]); + $models = $provider->getModels(); + $this->assertEquals(1, count($models)); + } + public function testActiveQuery() { $provider = new ActiveDataProvider([ From 2c22fe3f257212efb1ebeb07907cd86431319de1 Mon Sep 17 00:00:00 2001 From: Klimov Paul Date: Sat, 23 Nov 2013 18:22:54 +0200 Subject: [PATCH 50/59] Code style and docs at "yii\sphinx\*" fixed. --- extensions/sphinx/ActiveQuery.php | 18 ++++++++++++++++-- extensions/sphinx/ActiveRecord.php | 4 ++-- extensions/sphinx/Schema.php | 7 +------ 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/extensions/sphinx/ActiveQuery.php b/extensions/sphinx/ActiveQuery.php index a196b56..c56ea2b 100644 --- a/extensions/sphinx/ActiveQuery.php +++ b/extensions/sphinx/ActiveQuery.php @@ -67,15 +67,29 @@ class ActiveQuery extends Query implements ActiveQueryInterface public $sql; /** - * Sets the [[snippetCallback]] to [[fetchSnippetSourceFromModels]], which allows to + * Sets the [[snippetCallback]] to [[fetchSnippetSourceFromModels()]], which allows to * fetch the snippet source strings from the Active Record models, using method * [[ActiveRecord::getSnippetSource()]]. + * For example: + * + * ~~~ + * class Article extends ActiveRecord + * { + * public function getSnippetSource() + * { + * return file_get_contents('/path/to/source/files/' . $this->id . '.txt');; + * } + * } + * + * $articles = Article::find()->snippetByModel()->all(); + * ~~~ + * * Warning: this option should NOT be used with [[asArray]] at the same time! * @return static the query object itself */ public function snippetByModel() { - $this->snippetCallback(array($this, 'fetchSnippetSourceFromModels')); + $this->snippetCallback([$this, 'fetchSnippetSourceFromModels']); return $this; } diff --git a/extensions/sphinx/ActiveRecord.php b/extensions/sphinx/ActiveRecord.php index a59c065..d83db62 100644 --- a/extensions/sphinx/ActiveRecord.php +++ b/extensions/sphinx/ActiveRecord.php @@ -361,9 +361,9 @@ abstract class ActiveRecord extends Model * (because another user has modified the data), a [[StaleObjectException]] exception will be thrown, * and the update or deletion is skipped. * - * Optimized locking is only supported by [[update()]] and [[delete()]]. + * Optimistic locking is only supported by [[update()]] and [[delete()]]. * - * To use optimized locking: + * To use optimistic locking: * * 1. Create a column to store the version number of each row. The column type should be `BIGINT DEFAULT 0`. * Override this method to return the name of this column. diff --git a/extensions/sphinx/Schema.php b/extensions/sphinx/Schema.php index 242752c..6c9571c 100644 --- a/extensions/sphinx/Schema.php +++ b/extensions/sphinx/Schema.php @@ -317,13 +317,8 @@ class Schema extends Object if (!is_string($str)) { return $str; } - $this->db->open(); - if (($value = $this->db->pdo->quote($str)) !== false) { - return $value; - } else { // the driver doesn't support quote (e.g. oci) - return "'" . addcslashes(str_replace("'", "''", $str), "\000\n\r\\\032") . "'"; - } + return $this->db->pdo->quote($str); } /** From 5a8afcf7200fc3c60e971663b4413390216512e3 Mon Sep 17 00:00:00 2001 From: Klimov Paul Date: Sat, 23 Nov 2013 18:42:43 +0200 Subject: [PATCH 51/59] "yii\sphinx\ActiveQuery" updated to throw exception on conflict between "asArray" and "snippetByModel" options. --- extensions/sphinx/ActiveQuery.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/extensions/sphinx/ActiveQuery.php b/extensions/sphinx/ActiveQuery.php index c56ea2b..62a6ef0 100644 --- a/extensions/sphinx/ActiveQuery.php +++ b/extensions/sphinx/ActiveQuery.php @@ -7,6 +7,7 @@ namespace yii\sphinx; +use yii\base\InvalidCallException; use yii\db\ActiveQueryInterface; use yii\db\ActiveQueryTrait; @@ -186,10 +187,14 @@ class ActiveQuery extends Query implements ActiveQueryInterface /** * Fetches the source for the snippets using [[ActiveRecord::getSnippetSource()]] method. * @param ActiveRecord[] $models raw query result rows. + * @throws \yii\base\InvalidCallException if [[asArray]] enabled. * @return array snippet source strings */ protected function fetchSnippetSourceFromModels($models) { + if ($this->asArray) { + throw new InvalidCallException('"' . __METHOD__ . '" unable to determine snippet source from plain array. Either disable "asArray" option or use regular "snippetCallback"'); + } $result = []; foreach ($models as $model) { $result[] = $model->getSnippetSource(); From 78af586cd7ef7b76aa689cd30916aab887d6b93b Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Mon, 25 Nov 2013 02:22:46 +0400 Subject: [PATCH 52/59] Merged RBAC draft into authorization --- docs/guide/authorization.md | 117 ++++++++++++++++++++++++++++++++++++++++-- docs/guide/index.md | 1 - docs/guide/rbac.md | 122 -------------------------------------------- 3 files changed, 114 insertions(+), 126 deletions(-) delete mode 100644 docs/guide/rbac.md diff --git a/docs/guide/authorization.md b/docs/guide/authorization.md index b49f1af..5dedda9 100644 --- a/docs/guide/authorization.md +++ b/docs/guide/authorization.md @@ -77,15 +77,126 @@ Role based access control is very flexible approach to controlling access that i where permissions are customizable. In order to start using it some extra steps are required. First of all we need to configure `authManager` application -component: +component in application config file (`web.php` or `main.php` depending on template you've used): ```php +'authManager' => [ + 'class' => 'app\components\PhpManager', + 'defaultRoles' => ['guest'], +], +``` + +Often use role is stored in the same database table as other user data. In this case we may defined it by creating our +own component (`app/components/PhpManager.php`): + +```php +user->isGuest) { + // we suppose that user's role is stored in identity + $this->assign(Yii::$app->user->identity->id, Yii::$app->user->identity->role); + } + } +} ``` -Then create permissions hierarchy. +Then create permissions hierarchy in `@app/data/rbac.php`: + +```php + ['type' => Item::TYPE_OPERATION, 'description' => '...', 'bizRule' => NULL, 'data' => NULL], + 'manageThing1' => ['type' => Item::TYPE_OPERATION, 'description' => '...', 'bizRule' => NULL, 'data' => NULL], + 'manageThing2' => ['type' => Item::TYPE_OPERATION, 'description' => '...', 'bizRule' => NULL, 'data' => NULL], + 'manageThing2' => ['type' => Item::TYPE_OPERATION, 'description' => '...', 'bizRule' => NULL, 'data' => NULL], + + // AND THE ROLES + 'guest' => [ + 'type' => Item::TYPE_ROLE, + 'description' => 'Guest', + 'bizRule' => NULL, + 'data' => NULL + ], + + 'user' => [ + 'type' => Item::TYPE_ROLE, + 'description' => 'User', + 'children' => [ + 'guest', + 'manageThing0', // User can edit thing0 + ], + 'bizRule' => 'return !Yii::$app->user->isGuest;', + 'data' => NULL + ], + + 'moderator' => [ + 'type' => Item::TYPE_ROLE, + 'description' => 'Moderator', + 'children' => [ + 'user', // Can manage all that user can + 'manageThing1', // and also thing1 + ], + 'bizRule' => NULL, + 'data' => NULL + ], + + 'admin' => [ + 'type' => Item::TYPE_ROLE, + 'description' => 'Admin', + 'children' => [ + 'moderator', // can do all the stuff that moderator can + 'manageThing2', // and also manage thing2 + ], + 'bizRule' => NULL, + 'data' => NULL + ], + + 'godmode' => [ + 'type' => Item::TYPE_ROLE, + 'description' => 'Super admin', + 'children' => [ + 'admin', // can do all that admin can + 'manageThing3', // and also thing3 + ], + 'bizRule' => NULL, + 'data' => NULL + ], + +]; +``` + +Now you can specify roles from RBAC in controller's access control configuration: + +```php +public function behaviors() +{ + return [ + 'access' => [ + 'class' => 'yii\web\AccessControl', + 'except' => ['something'], + 'rules' => [ + [ + 'allow' => true, + 'roles' => ['manageThing1'], + ], + ], + ], + ]; +} +``` -Specify roles from RBAC in controller's access control configuration or call [[User::checkAccess()]] where appropriate. +Another way is to call [[User::checkAccess()]] where appropriate. ### How it works diff --git a/docs/guide/index.md b/docs/guide/index.md index 666de98..422ca64 100644 --- a/docs/guide/index.md +++ b/docs/guide/index.md @@ -56,7 +56,6 @@ Security and access control - [Authorization](authorization.md) - Access control and RBAC - [Security](security.md) - Hashing and verifying passwords, encryption - [Views security](view.md#security) - how to prevent XSS -- [RBAC](rbac.md) - Role-based Access Control Data providers, lists and grids =============================== diff --git a/docs/guide/rbac.md b/docs/guide/rbac.md deleted file mode 100644 index 28d8f5c..0000000 --- a/docs/guide/rbac.md +++ /dev/null @@ -1,122 +0,0 @@ -Using RBAC -=========== - -Lacking proper documentation, this guide is a stub copied from a [topic on the forum](http://www.yiiframework.com/forum/index.php/topic/49104-does-anyone-have-a-working-example-of-rbac/page__view__findpost__p__229098). - - -First af all, you modify your config (web.php or main.php), -```php -'authManager' => [ - 'class' => 'app\components\PhpManager', // THIS IS YOUR AUTH MANAGER - 'defaultRoles' => ['guest'], -], -``` - -Next, create the manager itself (app/components/PhpManager.php) -```php -authFile === NULL) - $this->authFile = Yii::getAlias('@app/data/rbac') . '.php'; // HERE GOES YOUR RBAC TREE FILE - - parent::init(); - - if (!Yii::$app->user->isGuest) { - $this->assign(Yii::$app->user->identity->id, Yii::$app->user->identity->role); // we suppose that user's role is stored in identity - } - } -} -``` - -Now, the rules tree (@app/data/rbac.php): -```php - ['type' => Item::TYPE_OPERATION, 'description' => '...', 'bizRule' => NULL, 'data' => NULL], - 'manageThing1' => ['type' => Item::TYPE_OPERATION, 'description' => '...', 'bizRule' => NULL, 'data' => NULL], - 'manageThing2' => ['type' => Item::TYPE_OPERATION, 'description' => '...', 'bizRule' => NULL, 'data' => NULL], - 'manageThing2' => ['type' => Item::TYPE_OPERATION, 'description' => '...', 'bizRule' => NULL, 'data' => NULL], - - // AND THE ROLES - 'guest' => [ - 'type' => Item::TYPE_ROLE, - 'description' => 'Guest', - 'bizRule' => NULL, - 'data' => NULL - ], - - 'user' => [ - 'type' => Item::TYPE_ROLE, - 'description' => 'User', - 'children' => [ - 'guest', - 'manageThing0', // User can edit thing0 - ], - 'bizRule' => 'return !Yii::$app->user->isGuest;', - 'data' => NULL - ], - - 'moderator' => [ - 'type' => Item::TYPE_ROLE, - 'description' => 'Moderator', - 'children' => [ - 'user', // Can manage all that user can - 'manageThing1', // and also thing1 - ], - 'bizRule' => NULL, - 'data' => NULL - ], - - 'admin' => [ - 'type' => Item::TYPE_ROLE, - 'description' => 'Admin', - 'children' => [ - 'moderator', // can do all the stuff that moderator can - 'manageThing2', // and also manage thing2 - ], - 'bizRule' => NULL, - 'data' => NULL - ], - - 'godmode' => [ - 'type' => Item::TYPE_ROLE, - 'description' => 'Super admin', - 'children' => [ - 'admin', // can do all that admin can - 'manageThing3', // and also thing3 - ], - 'bizRule' => NULL, - 'data' => NULL - ], - -]; -``` - -As a result, you can now add access control filters to controllers -```php -public function behaviors() -{ - return [ - 'access' => [ - 'class' => 'yii\web\AccessControl', - 'except' => ['something'], - 'rules' => [ - [ - 'allow' => true, - 'roles' => ['manageThing1'], - ], - ], - ], - ]; -} -``` From 72dd86df1579114076baf65a53c973f0c28f714b Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Mon, 25 Nov 2013 02:36:52 +0400 Subject: [PATCH 53/59] fixes #1158 mentioned @web alias in docs --- docs/guide/basics.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/guide/basics.md b/docs/guide/basics.md index 40edd92..914797c 100644 --- a/docs/guide/basics.md +++ b/docs/guide/basics.md @@ -63,9 +63,9 @@ Path Aliases Yii 2.0 expands the usage of path aliases to both file/directory paths and URLs. An alias must start with a `@` character so that it can be differentiated from file/directory paths and URLs. -For example, the alias `@yii` refers to the Yii installation directory. Path aliases are -supported in most places in the Yii core code. For example, `FileCache::cachePath` can take -both a path alias and a normal directory path. +For example, the alias `@yii` refers to the Yii installation directory while `@web` contains base URL for currently +running web application. Path aliases are supported in most places in the Yii core code. For example, +`FileCache::cachePath` can take both a path alias and a normal directory path. Path alias is also closely related with class namespaces. It is recommended that a path alias be defined for each root namespace so that you can use Yii the class autoloader without From 4b353c7bcea8b61592177c190ee6103f812e146c Mon Sep 17 00:00:00 2001 From: Qiang Xue Date: Sun, 24 Nov 2013 17:41:09 -0500 Subject: [PATCH 54/59] Fixes #1297: CSRF not generated on error pages --- docs/guide/error.md | 10 +++++----- framework/yii/base/Application.php | 8 ++++++++ framework/yii/base/ErrorHandler.php | 4 +--- framework/yii/web/Controller.php | 2 +- framework/yii/web/ErrorAction.php | 2 +- 5 files changed, 16 insertions(+), 10 deletions(-) diff --git a/docs/guide/error.md b/docs/guide/error.md index f5aa615..d0418b4 100644 --- a/docs/guide/error.md +++ b/docs/guide/error.md @@ -22,14 +22,14 @@ return [ ], ``` -After it is done in case of error Yii will launch `SiteController::actionError()`. Since errors are converted to -exceptions we can get exception from error handler: +After it is done in case of error, Yii will launch `SiteController::actionError()`: ```php public function actionError() { - $exception = \Yii::$app->getErrorHandler()->exception; - $this->render('myerror', ['message' => $exception->getMessage()]); + if (\Yii::$app->exception !== null) { + return $this->render('error', ['exception' => \Yii::$app->exception]); + } } ``` @@ -48,7 +48,7 @@ public function actions() ``` After defining `actions` in `SiteController` as shown above you can create `views/site/error.php`. In the view there -are three varialbes available: +are three variables available: - `$name`: the error name - `$message`: the error message diff --git a/framework/yii/base/Application.php b/framework/yii/base/Application.php index dc363f1..d6409e8 100644 --- a/framework/yii/base/Application.php +++ b/framework/yii/base/Application.php @@ -127,6 +127,11 @@ abstract class Application extends Module * ~~~ */ public $extensions = []; + /** + * @var \Exception the exception that is being handled currently. When this is not null, + * it means the application is handling some exception and extra care should be taken. + */ + public $exception; /** * @var string Used to reserve memory for fatal error handler. @@ -487,6 +492,8 @@ abstract class Application extends Module */ public function handleException($exception) { + $this->exception = $exception; + // disable error capturing to avoid recursive errors while handling exceptions restore_error_handler(); restore_exception_handler(); @@ -574,6 +581,7 @@ abstract class Application extends Module if (ErrorException::isFatalError($error)) { $exception = new ErrorException($error['message'], $error['type'], $error['type'], $error['file'], $error['line']); + $this->exception = $exception; // use error_log because it's too late to use Yii log error_log($exception); diff --git a/framework/yii/base/ErrorHandler.php b/framework/yii/base/ErrorHandler.php index c96ca5e..1014f71 100644 --- a/framework/yii/base/ErrorHandler.php +++ b/framework/yii/base/ErrorHandler.php @@ -40,7 +40,7 @@ class ErrorHandler extends Component /** * @var string the route (e.g. 'site/error') to the controller action that will be used * to display external errors. Inside the action, it can retrieve the error information - * by Yii::$app->errorHandler->exception. This property defaults to null, meaning ErrorHandler + * by Yii::$app->exception. This property defaults to null, meaning ErrorHandler * will handle the error display. */ public $errorAction; @@ -96,8 +96,6 @@ class ErrorHandler extends Component $response->getHeaders()->removeAll(); if ($useErrorView && $this->errorAction !== null) { - // disable CSRF validation so that errorAction can run in case the error is caused by CSRF validation failure - Yii::$app->getRequest()->enableCsrfValidation = false; $result = Yii::$app->runAction($this->errorAction); if ($result instanceof Response) { $response = $result; diff --git a/framework/yii/web/Controller.php b/framework/yii/web/Controller.php index 71e7793..5c68cdb 100644 --- a/framework/yii/web/Controller.php +++ b/framework/yii/web/Controller.php @@ -91,7 +91,7 @@ class Controller extends \yii\base\Controller public function beforeAction($action) { if (parent::beforeAction($action)) { - if ($this->enableCsrfValidation && !Yii::$app->getRequest()->validateCsrfToken()) { + if ($this->enableCsrfValidation && Yii::$app->exception === null && !Yii::$app->getRequest()->validateCsrfToken()) { throw new HttpException(400, Yii::t('yii', 'Unable to verify your data submission.')); } return true; diff --git a/framework/yii/web/ErrorAction.php b/framework/yii/web/ErrorAction.php index bdbb4c5..95f17be 100644 --- a/framework/yii/web/ErrorAction.php +++ b/framework/yii/web/ErrorAction.php @@ -69,7 +69,7 @@ class ErrorAction extends Action public function run() { - if (!($exception = Yii::$app->getErrorHandler()->exception)) { + if (($exception = Yii::$app->exception) === null) { return ''; } From b259f991c5bbc4d4c9588a71db060e55f7bbb535 Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Mon, 25 Nov 2013 02:54:31 +0400 Subject: [PATCH 55/59] Added initial steps about configuring DB-based RBAC --- docs/guide/authorization.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/docs/guide/authorization.md b/docs/guide/authorization.md index 5dedda9..8f2b97f 100644 --- a/docs/guide/authorization.md +++ b/docs/guide/authorization.md @@ -76,6 +76,8 @@ Role based access control (RBAC) Role based access control is very flexible approach to controlling access that is a perfect match for complex systems where permissions are customizable. +### Using file-based config for RBAC + In order to start using it some extra steps are required. First of all we need to configure `authManager` application component in application config file (`web.php` or `main.php` depending on template you've used): @@ -198,6 +200,26 @@ public function behaviors() Another way is to call [[User::checkAccess()]] where appropriate. +### Using DB-based storage for RBAC + +Storing RBAC hierarchy in database is less efficient performancewise but is much more flexible. It is easier to create +a good management UI for it so in case you need permissions structure that is managed by end user DB is your choice. + +In order to get started you need to configure database connection in `db` component. After it is done [get `schema-*.sql` +file for your database](https://github.com/yiisoft/yii2/tree/master/framework/yii/rbac) and execute it. + +Next step is to configure `authManager` application component in application config file (`web.php` or `main.php` +depending on template you've used): + +```php +'authManager' => [ + 'class' => 'yii\rbac\DbManager', + 'defaultRoles' => ['guest'], +], +``` + +TBD + ### How it works TBD: write about how it works with pictures :) From d2614875e0d166e88f92e2aed4c508ee06241ac9 Mon Sep 17 00:00:00 2001 From: Qiang Xue Date: Sun, 24 Nov 2013 18:14:54 -0500 Subject: [PATCH 56/59] update composer to include sphinx --- composer.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/composer.json b/composer.json index 5ab93fa..8173b2a 100644 --- a/composer.json +++ b/composer.json @@ -71,6 +71,7 @@ "yiisoft/yii2-jui": "self.version", "yiisoft/yii2-smarty": "self.version", "yiisoft/yii2-swiftmailer": "self.version", + "yiisoft/yii2-sphinx": "self.version", "yiisoft/yii2-twig": "self.version", "yiisoft/yii2": "self.version" }, @@ -96,6 +97,7 @@ "yii\\jui\\": "extensions/jui/", "yii\\smarty\\": "extensions/smarty/", "yii\\swiftmailer\\": "extensions/swiftmailer/", + "yii\\sphinx\\": "extensions/sphinx/", "yii\\twig\\": "extensions/twig/", "yii\\": "framework/yii/" } From 4c1b823d03c5451e2d394ff3b243a8a49779ebed Mon Sep 17 00:00:00 2001 From: Qiang Xue Date: Sun, 24 Nov 2013 18:56:34 -0500 Subject: [PATCH 57/59] fixed whitespaces --- composer.json | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/composer.json b/composer.json index 8173b2a..ee0766a 100644 --- a/composer.json +++ b/composer.json @@ -63,18 +63,18 @@ "irc": "irc://irc.freenode.net/yii", "source": "https://github.com/yiisoft/yii2" }, - "minimum-stability": "dev", - "replace": { - "yiisoft/yii2-bootstrap": "self.version", - "yiisoft/yii2-debug": "self.version", - "yiisoft/yii2-gii": "self.version", - "yiisoft/yii2-jui": "self.version", - "yiisoft/yii2-smarty": "self.version", - "yiisoft/yii2-swiftmailer": "self.version", + "minimum-stability": "dev", + "replace": { + "yiisoft/yii2-bootstrap": "self.version", + "yiisoft/yii2-debug": "self.version", + "yiisoft/yii2-gii": "self.version", + "yiisoft/yii2-jui": "self.version", + "yiisoft/yii2-smarty": "self.version", + "yiisoft/yii2-swiftmailer": "self.version", "yiisoft/yii2-sphinx": "self.version", - "yiisoft/yii2-twig": "self.version", - "yiisoft/yii2": "self.version" - }, + "yiisoft/yii2-twig": "self.version", + "yiisoft/yii2": "self.version" + }, "require": { "php": ">=5.4.0", "ext-mbstring": "*", @@ -91,7 +91,7 @@ }, "autoload": { "psr-0": { - "yii\\bootstrap\\": "extensions/bootstrap/", + "yii\\bootstrap\\": "extensions/bootstrap/", "yii\\debug\\": "extensions/debug/", "yii\\gii\\": "extensions/gii/", "yii\\jui\\": "extensions/jui/", @@ -100,6 +100,6 @@ "yii\\sphinx\\": "extensions/sphinx/", "yii\\twig\\": "extensions/twig/", "yii\\": "framework/yii/" - } - } + } + } } From 8ac2b7364d6435683505fd6b24bb36ab4ec1438a Mon Sep 17 00:00:00 2001 From: Carsten Brandt Date: Mon, 25 Nov 2013 02:23:51 +0100 Subject: [PATCH 58/59] moved redis to extensions --- composer.json | 2 + extensions/redis/ActiveQuery.php | 382 +++++++++++++++++ extensions/redis/ActiveRecord.php | 322 ++++++++++++++ extensions/redis/ActiveRelation.php | 67 +++ extensions/redis/Cache.php | 184 ++++++++ extensions/redis/Connection.php | 406 ++++++++++++++++++ extensions/redis/LICENSE.md | 32 ++ extensions/redis/LuaScriptBuilder.php | 365 ++++++++++++++++ extensions/redis/README.md | 91 ++++ extensions/redis/composer.json | 27 ++ extensions/sphinx/composer.json | 2 +- framework/yii/caching/RedisCache.php | 218 ---------- framework/yii/classes.php | 3 - framework/yii/redis/ActiveQuery.php | 382 ----------------- framework/yii/redis/ActiveRecord.php | 322 -------------- framework/yii/redis/ActiveRelation.php | 67 --- framework/yii/redis/Connection.php | 405 ------------------ framework/yii/redis/LuaScriptBuilder.php | 365 ---------------- tests/unit/data/config.php | 4 +- tests/unit/extensions/redis/ActiveRecordTest.php | 465 ++++++++++++++++++++ tests/unit/extensions/redis/RedisCacheTest.php | 101 +++++ .../unit/extensions/redis/RedisConnectionTest.php | 58 +++ tests/unit/extensions/redis/RedisTestCase.php | 48 +++ tests/unit/framework/caching/RedisCacheTest.php | 95 ----- tests/unit/framework/redis/ActiveRecordTest.php | 466 --------------------- tests/unit/framework/redis/RedisConnectionTest.php | 69 --- tests/unit/framework/redis/RedisTestCase.php | 51 --- 27 files changed, 2554 insertions(+), 2445 deletions(-) create mode 100644 extensions/redis/ActiveQuery.php create mode 100644 extensions/redis/ActiveRecord.php create mode 100644 extensions/redis/ActiveRelation.php create mode 100644 extensions/redis/Cache.php create mode 100644 extensions/redis/Connection.php create mode 100644 extensions/redis/LICENSE.md create mode 100644 extensions/redis/LuaScriptBuilder.php create mode 100644 extensions/redis/README.md create mode 100644 extensions/redis/composer.json delete mode 100644 framework/yii/caching/RedisCache.php delete mode 100644 framework/yii/redis/ActiveQuery.php delete mode 100644 framework/yii/redis/ActiveRecord.php delete mode 100644 framework/yii/redis/ActiveRelation.php delete mode 100644 framework/yii/redis/Connection.php delete mode 100644 framework/yii/redis/LuaScriptBuilder.php create mode 100644 tests/unit/extensions/redis/ActiveRecordTest.php create mode 100644 tests/unit/extensions/redis/RedisCacheTest.php create mode 100644 tests/unit/extensions/redis/RedisConnectionTest.php create mode 100644 tests/unit/extensions/redis/RedisTestCase.php delete mode 100644 tests/unit/framework/caching/RedisCacheTest.php delete mode 100644 tests/unit/framework/redis/ActiveRecordTest.php delete mode 100644 tests/unit/framework/redis/RedisConnectionTest.php delete mode 100644 tests/unit/framework/redis/RedisTestCase.php diff --git a/composer.json b/composer.json index ee0766a..4cc1743 100644 --- a/composer.json +++ b/composer.json @@ -69,6 +69,7 @@ "yiisoft/yii2-debug": "self.version", "yiisoft/yii2-gii": "self.version", "yiisoft/yii2-jui": "self.version", + "yiisoft/yii2-redis": "self.version", "yiisoft/yii2-smarty": "self.version", "yiisoft/yii2-swiftmailer": "self.version", "yiisoft/yii2-sphinx": "self.version", @@ -95,6 +96,7 @@ "yii\\debug\\": "extensions/debug/", "yii\\gii\\": "extensions/gii/", "yii\\jui\\": "extensions/jui/", + "yii\\redis\\": "extensions/redis/", "yii\\smarty\\": "extensions/smarty/", "yii\\swiftmailer\\": "extensions/swiftmailer/", "yii\\sphinx\\": "extensions/sphinx/", diff --git a/extensions/redis/ActiveQuery.php b/extensions/redis/ActiveQuery.php new file mode 100644 index 0000000..2174901 --- /dev/null +++ b/extensions/redis/ActiveQuery.php @@ -0,0 +1,382 @@ +with('orders')->asArray()->all(); + * ~~~ + * + * @author Carsten Brandt + * @since 2.0 + */ +class ActiveQuery extends \yii\base\Component implements ActiveQueryInterface +{ + use QueryTrait; + use ActiveQueryTrait; + + /** + * Executes the query and returns all results as an array. + * @param Connection $db the database connection used to execute the query. + * If this parameter is not given, the `db` application component will be used. + * @return array|ActiveRecord[] the query results. If the query results in nothing, an empty array will be returned. + */ + public function all($db = null) + { + // TODO add support for orderBy + $data = $this->executeScript($db, 'All'); + $rows = []; + foreach($data as $dataRow) { + $row = []; + $c = count($dataRow); + for($i = 0; $i < $c; ) { + $row[$dataRow[$i++]] = $dataRow[$i++]; + } + $rows[] = $row; + } + if (!empty($rows)) { + $models = $this->createModels($rows); + if (!empty($this->with)) { + $this->findWith($this->with, $models); + } + return $models; + } else { + return []; + } + } + + /** + * Executes the query and returns a single row of result. + * @param Connection $db the database connection used to execute the query. + * If this parameter is not given, the `db` application component will be used. + * @return ActiveRecord|array|null a single row of query result. Depending on the setting of [[asArray]], + * the query result may be either an array or an ActiveRecord object. Null will be returned + * if the query results in nothing. + */ + public function one($db = null) + { + // TODO add support for orderBy + $data = $this->executeScript($db, 'One'); + if (empty($data)) { + return null; + } + $row = []; + $c = count($data); + for($i = 0; $i < $c; ) { + $row[$data[$i++]] = $data[$i++]; + } + if ($this->asArray) { + $model = $row; + } else { + /** @var ActiveRecord $class */ + $class = $this->modelClass; + $model = $class::create($row); + } + if (!empty($this->with)) { + $models = [$model]; + $this->findWith($this->with, $models); + $model = $models[0]; + } + return $model; + } + + /** + * Returns the number of records. + * @param string $q the COUNT expression. This parameter is ignored by this implementation. + * @param Connection $db the database connection used to execute the query. + * If this parameter is not given, the `db` application component will be used. + * @return integer number of records + */ + public function count($q = '*', $db = null) + { + if ($this->offset === null && $this->limit === null && $this->where === null) { + /** @var ActiveRecord $modelClass */ + $modelClass = $this->modelClass; + if ($db === null) { + $db = $modelClass::getDb(); + } + return $db->executeCommand('LLEN', [$modelClass::tableName()]); + } else { + return $this->executeScript($db, 'Count'); + } + } + + /** + * Returns a value indicating whether the query result contains any row of data. + * @param Connection $db the database connection used to execute the query. + * If this parameter is not given, the `db` application component will be used. + * @return boolean whether the query result contains any row of data. + */ + public function exists($db = null) + { + return $this->one($db) !== null; + } + + /** + * Executes the query and returns the first column of the result. + * @param string $column name of the column to select + * @param Connection $db the database connection used to execute the query. + * If this parameter is not given, the `db` application component will be used. + * @return array the first column of the query result. An empty array is returned if the query results in nothing. + */ + public function column($column, $db = null) + { + // TODO add support for orderBy + return $this->executeScript($db, 'Column', $column); + } + + /** + * Returns the number of records. + * @param string $column the column to sum up + * @param Connection $db the database connection used to execute the query. + * If this parameter is not given, the `db` application component will be used. + * @return integer number of records + */ + public function sum($column, $db = null) + { + return $this->executeScript($db, 'Sum', $column); + } + + /** + * Returns the average of the specified column values. + * @param string $column the column name or expression. + * Make sure you properly quote column names in the expression. + * @param Connection $db the database connection used to execute the query. + * If this parameter is not given, the `db` application component will be used. + * @return integer the average of the specified column values. + */ + public function average($column, $db = null) + { + return $this->executeScript($db, 'Average', $column); + } + + /** + * Returns the minimum of the specified column values. + * @param string $column the column name or expression. + * Make sure you properly quote column names in the expression. + * @param Connection $db the database connection used to execute the query. + * If this parameter is not given, the `db` application component will be used. + * @return integer the minimum of the specified column values. + */ + public function min($column, $db = null) + { + return $this->executeScript($db, 'Min', $column); + } + + /** + * Returns the maximum of the specified column values. + * @param string $column the column name or expression. + * Make sure you properly quote column names in the expression. + * @param Connection $db the database connection used to execute the query. + * If this parameter is not given, the `db` application component will be used. + * @return integer the maximum of the specified column values. + */ + public function max($column, $db = null) + { + return $this->executeScript($db, 'Max', $column); + } + + /** + * Returns the query result as a scalar value. + * The value returned will be the specified attribute in the first record of the query results. + * @param string $attribute name of the attribute to select + * @param Connection $db the database connection used to execute the query. + * If this parameter is not given, the `db` application component will be used. + * @return string the value of the specified attribute in the first record of the query result. + * Null is returned if the query result is empty. + */ + public function scalar($attribute, $db = null) + { + $record = $this->one($db); + if ($record !== null) { + return $record->$attribute; + } else { + return null; + } + } + + + /** + * Executes a script created by [[LuaScriptBuilder]] + * @param Connection $db the database connection used to execute the query. + * If this parameter is not given, the `db` application component will be used. + * @param string $type the type of the script to generate + * @param string $columnName + * @return array|bool|null|string + */ + protected function executeScript($db, $type, $columnName = null) + { + if (!empty($this->orderBy)) { + throw new NotSupportedException('orderBy is currently not supported by redis ActiveRecord.'); + } + + /** @var ActiveRecord $modelClass */ + $modelClass = $this->modelClass; + + if ($db === null) { + $db = $modelClass::getDb(); + } + + // find by primary key if possible. This is much faster than scanning all records + if (is_array($this->where) && !isset($this->where[0]) && $modelClass::isPrimaryKey(array_keys($this->where))) { + return $this->findByPk($db, $type, $columnName); + } + + $method = 'build' . $type; + $script = $db->getLuaScriptBuilder()->$method($this, $columnName); + return $db->executeCommand('EVAL', [$script, 0]); + } + + /** + * Fetch by pk if possible as this is much faster + * @param Connection $db the database connection used to execute the query. + * If this parameter is not given, the `db` application component will be used. + * @param string $type the type of the script to generate + * @param string $columnName + * @return array|bool|null|string + * @throws \yii\base\InvalidParamException + * @throws \yii\base\NotSupportedException + */ + private function findByPk($db, $type, $columnName = null) + { + if (count($this->where) == 1) { + $pks = (array) reset($this->where); + } else { + foreach($this->where as $column => $values) { + if (is_array($values)) { + // TODO support composite IN for composite PK + throw new NotSupportedException('Find by composite PK is not supported by redis ActiveRecord.'); + } + } + $pks = [$this->where]; + } + + /** @var ActiveRecord $modelClass */ + $modelClass = $this->modelClass; + + $start = $this->offset === null ? 0 : $this->offset; + $i = 0; + $data = []; + foreach($pks as $pk) { + if (++$i > $start && ($this->limit === null || $i <= $start + $this->limit)) { + $key = $modelClass::tableName() . ':a:' . $modelClass::buildKey($pk); + $result = $db->executeCommand('HGETALL', [$key]); + if (!empty($result)) { + $data[] = $result; + if ($type === 'One' && $this->orderBy === null) { + break; + } + } + } + } + // TODO support orderBy + + switch($type) { + case 'All': + return $data; + case 'One': + return reset($data); + case 'Count': + return count($data); + case 'Column': + $column = []; + foreach($data as $dataRow) { + $row = []; + $c = count($dataRow); + for($i = 0; $i < $c; ) { + $row[$dataRow[$i++]] = $dataRow[$i++]; + } + $column[] = $row[$columnName]; + } + return $column; + case 'Sum': + $sum = 0; + foreach($data as $dataRow) { + $c = count($dataRow); + for($i = 0; $i < $c; ) { + if ($dataRow[$i++] == $columnName) { + $sum += $dataRow[$i]; + break; + } + } + } + return $sum; + case 'Average': + $sum = 0; + $count = 0; + foreach($data as $dataRow) { + $count++; + $c = count($dataRow); + for($i = 0; $i < $c; ) { + if ($dataRow[$i++] == $columnName) { + $sum += $dataRow[$i]; + break; + } + } + } + return $sum / $count; + case 'Min': + $min = null; + foreach($data as $dataRow) { + $c = count($dataRow); + for($i = 0; $i < $c; ) { + if ($dataRow[$i++] == $columnName && ($min == null || $dataRow[$i] < $min)) { + $min = $dataRow[$i]; + break; + } + } + } + return $min; + case 'Max': + $max = null; + foreach($data as $dataRow) { + $c = count($dataRow); + for($i = 0; $i < $c; ) { + if ($dataRow[$i++] == $columnName && ($max == null || $dataRow[$i] > $max)) { + $max = $dataRow[$i]; + break; + } + } + } + return $max; + } + throw new InvalidParamException('Unknown fetch type: ' . $type); + } +} diff --git a/extensions/redis/ActiveRecord.php b/extensions/redis/ActiveRecord.php new file mode 100644 index 0000000..46132fc --- /dev/null +++ b/extensions/redis/ActiveRecord.php @@ -0,0 +1,322 @@ + + * @since 2.0 + */ +class ActiveRecord extends \yii\db\ActiveRecord +{ + /** + * Returns the database connection used by this AR class. + * By default, the "redis" application component is used as the database connection. + * You may override this method if you want to use a different database connection. + * @return Connection the database connection used by this AR class. + */ + public static function getDb() + { + return \Yii::$app->getComponent('redis'); + } + + /** + * @inheritDoc + */ + public static function createQuery() + { + return new ActiveQuery(['modelClass' => get_called_class()]); + } + + /** + * @inheritDoc + */ + public static function createActiveRelation($config = []) + { + return new ActiveRelation($config); + } + + /** + * Returns the primary key name(s) for this AR class. + * This method should be overridden by child classes to define the primary key. + * + * Note that an array should be returned even when it is a single primary key. + * + * @return string[] the primary keys of this record. + */ + public static function primaryKey() + { + return ['id']; + } + + /** + * Returns the list of all attribute names of the model. + * This method must be overridden by child classes to define available attributes. + * @return array list of attribute names. + */ + public static function attributes() + { + throw new InvalidConfigException('The attributes() method of redis ActiveRecord has to be implemented by child classes.'); + } + + /** + * @inheritDocs + */ + public function insert($runValidation = true, $attributes = null) + { + if ($runValidation && !$this->validate($attributes)) { + return false; + } + if ($this->beforeSave(true)) { + $db = static::getDb(); + $values = $this->getDirtyAttributes($attributes); + $pk = []; +// if ($values === []) { + foreach ($this->primaryKey() as $key) { + $pk[$key] = $values[$key] = $this->getAttribute($key); + if ($pk[$key] === null) { + $pk[$key] = $values[$key] = $db->executeCommand('INCR', [static::tableName() . ':s:' . $key]); + $this->setAttribute($key, $values[$key]); + } + } +// } + // save pk in a findall pool + $db->executeCommand('RPUSH', [static::tableName(), static::buildKey($pk)]); + + $key = static::tableName() . ':a:' . static::buildKey($pk); + // save attributes + $args = [$key]; + foreach($values as $attribute => $value) { + $args[] = $attribute; + $args[] = $value; + } + $db->executeCommand('HMSET', $args); + + $this->setOldAttributes($values); + $this->afterSave(true); + return true; + } + return false; + } + + /** + * Updates the whole table 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], ['id' => 2]); + * ~~~ + * + * @param array $attributes attribute values (name-value pairs) to be saved into the table + * @param array $condition the conditions that will be put in the WHERE part of the UPDATE SQL. + * Please refer to [[ActiveQuery::where()]] on how to specify this parameter. + * @param array $params this parameter is ignored in redis implementation. + * @return integer the number of rows updated + */ + public static function updateAll($attributes, $condition = null, $params = []) + { + if (empty($attributes)) { + return 0; + } + $db = static::getDb(); + $n=0; + foreach(static::fetchPks($condition) as $pk) { + $newPk = $pk; + $pk = static::buildKey($pk); + $key = static::tableName() . ':a:' . $pk; + // save attributes + $args = [$key]; + foreach($attributes as $attribute => $value) { + if (isset($newPk[$attribute])) { + $newPk[$attribute] = $value; + } + $args[] = $attribute; + $args[] = $value; + } + $newPk = static::buildKey($newPk); + $newKey = static::tableName() . ':a:' . $newPk; + // rename index if pk changed + if ($newPk != $pk) { + $db->executeCommand('MULTI'); + $db->executeCommand('HMSET', $args); + $db->executeCommand('LINSERT', [static::tableName(), 'AFTER', $pk, $newPk]); + $db->executeCommand('LREM', [static::tableName(), 0, $pk]); + $db->executeCommand('RENAME', [$key, $newKey]); + $db->executeCommand('EXEC'); + } else { + $db->executeCommand('HMSET', $args); + } + $n++; + } + return $n; + } + + /** + * Updates the whole table using the provided counter changes and conditions. + * For example, to increment all customers' age by 1, + * + * ~~~ + * Customer::updateAllCounters(['age' => 1]); + * ~~~ + * + * @param array $counters the counters to be updated (attribute name => increment value). + * Use negative values if you want to decrement the counters. + * @param array $condition the conditions that will be put in the WHERE part of the UPDATE SQL. + * Please refer to [[ActiveQuery::where()]] on how to specify this parameter. + * @param array $params this parameter is ignored in redis implementation. + * @return integer the number of rows updated + */ + public static function updateAllCounters($counters, $condition = null, $params = []) + { + if (empty($counters)) { + return 0; + } + $db = static::getDb(); + $n=0; + foreach(static::fetchPks($condition) as $pk) { + $key = static::tableName() . ':a:' . static::buildKey($pk); + foreach($counters as $attribute => $value) { + $db->executeCommand('HINCRBY', [$key, $attribute, $value]); + } + $n++; + } + return $n; + } + + /** + * Deletes rows in the table 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 conditions that will be put in the WHERE part of the DELETE SQL. + * Please refer to [[ActiveQuery::where()]] on how to specify this parameter. + * @param array $params this parameter is ignored in redis implementation. + * @return integer the number of rows deleted + */ + public static function deleteAll($condition = null, $params = []) + { + $db = static::getDb(); + $attributeKeys = []; + $pks = static::fetchPks($condition); + $db->executeCommand('MULTI'); + foreach($pks as $pk) { + $pk = static::buildKey($pk); + $db->executeCommand('LREM', [static::tableName(), 0, $pk]); + $attributeKeys[] = static::tableName() . ':a:' . $pk; + } + if (empty($attributeKeys)) { + $db->executeCommand('EXEC'); + return 0; + } + $db->executeCommand('DEL', $attributeKeys); + $result = $db->executeCommand('EXEC'); + return end($result); + } + + private static function fetchPks($condition) + { + $query = static::createQuery(); + $query->where($condition); + $records = $query->asArray()->all(); // TODO limit fetched columns to pk + $primaryKey = static::primaryKey(); + + $pks = []; + foreach($records as $record) { + $pk = []; + foreach($primaryKey as $key) { + $pk[$key] = $record[$key]; + } + $pks[] = $pk; + } + return $pks; + } + + /** + * Builds a normalized key from a given primary key value. + * + * @param mixed $key the key to be normalized + * @return string the generated key + */ + public static function buildKey($key) + { + if (is_numeric($key)) { + return $key; + } elseif (is_string($key)) { + return ctype_alnum($key) && StringHelper::strlen($key) <= 32 ? $key : md5($key); + } elseif (is_array($key)) { + if (count($key) == 1) { + return self::buildKey(reset($key)); + } + ksort($key); // ensure order is always the same + $isNumeric = true; + foreach($key as $value) { + if (!is_numeric($value)) { + $isNumeric = false; + } + } + if ($isNumeric) { + return implode('-', $key); + } + } + return md5(json_encode($key)); + } + + /** + * @inheritdoc + */ + public static function getTableSchema() + { + throw new NotSupportedException('getTableSchema() is not supported by redis ActiveRecord'); + } + + /** + * @inheritdoc + */ + public static function findBySql($sql, $params = []) + { + throw new NotSupportedException('findBySql() is not supported by redis ActiveRecord'); + } + + /** + * Returns a value indicating whether the specified operation is transactional in the current [[scenario]]. + * This method will always return false as transactional operations are not supported by redis. + * @param integer $operation the operation to check. Possible values are [[OP_INSERT]], [[OP_UPDATE]] and [[OP_DELETE]]. + * @return boolean whether the specified operation is transactional in the current [[scenario]]. + */ + public function isTransactional($operation) + { + return false; + } +} diff --git a/extensions/redis/ActiveRelation.php b/extensions/redis/ActiveRelation.php new file mode 100644 index 0000000..b2f5cea --- /dev/null +++ b/extensions/redis/ActiveRelation.php @@ -0,0 +1,67 @@ + + * @since 2.0 + */ +class ActiveRelation extends ActiveQuery implements ActiveRelationInterface +{ + use ActiveRelationTrait; + + /** + * Executes a script created by [[LuaScriptBuilder]] + * @param Connection $db the database connection used to execute the query. + * If this parameter is not given, the `db` application component will be used. + * @param string $type the type of the script to generate + * @param null $column + * @return array|bool|null|string + */ + protected function executeScript($db, $type, $column=null) + { + if ($this->primaryModel !== null) { + // lazy loading + if ($this->via instanceof self) { + // via pivot table + $viaModels = $this->via->findPivotRows([$this->primaryModel]); + $this->filterByModels($viaModels); + } elseif (is_array($this->via)) { + // via relation + /** @var ActiveRelation $viaQuery */ + 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::executeScript($db, $type, $column); + } +} diff --git a/extensions/redis/Cache.php b/extensions/redis/Cache.php new file mode 100644 index 0000000..f1a21ed --- /dev/null +++ b/extensions/redis/Cache.php @@ -0,0 +1,184 @@ + [ + * 'cache' => [ + * 'class' => 'yii\redis\Cache', + * ], + * 'redis' => [ + * 'class' => 'yii\redis\Connection', + * 'hostname' => 'localhost', + * 'port' => 6379, + * 'database' => 0, + * ] + * ], + * ] + * ~~~ + * + * @property Connection $connection The redis connection object. This property is read-only. + * + * @author Carsten Brandt + * @since 2.0 + */ +class Cache extends \yii\caching\Cache +{ + /** + * @var string the id of the application component to use as the redis connection. + * It should be configured as a [[yii\redis\Connection]]. Defaults to `redis`. + */ + public $connectionId = 'redis'; + + + /** + * Checks whether a specified key exists in the cache. + * This can be faster than getting the value from the cache if the data is big. + * Note that this method does not check whether the dependency associated + * with the cached data, if there is any, has changed. So a call to [[get]] + * may return false while exists returns true. + * @param mixed $key a key identifying the cached value. This can be a simple string or + * a complex data structure consisting of factors representing the key. + * @return boolean true if a value exists in cache, false if the value is not in the cache or expired. + */ + public function exists($key) + { + /** @var Connection $connection */ + $connection = \Yii::$app->getComponent($this->connectionId); + return (bool) $connection->executeCommand('EXISTS', [$this->buildKey($key)]); + } + + /** + * @inheritDocs + */ + protected function getValue($key) + { + /** @var Connection $connection */ + $connection = \Yii::$app->getComponent($this->connectionId); + return $connection->executeCommand('GET', [$key]); + } + + /** + * @inheritDocs + */ + protected function getValues($keys) + { + /** @var Connection $connection */ + $connection = \Yii::$app->getComponent($this->connectionId); + $response = $connection->executeCommand('MGET', $keys); + $result = []; + $i = 0; + foreach ($keys as $key) { + $result[$key] = $response[$i++]; + } + return $result; + } + + /** + * @inheritDocs + */ + protected function setValue($key, $value, $expire) + { + /** @var Connection $connection */ + $connection = \Yii::$app->getComponent($this->connectionId); + if ($expire == 0) { + return (bool) $connection->executeCommand('SET', [$key, $value]); + } else { + $expire = (int) ($expire * 1000); + return (bool) $connection->executeCommand('SET', [$key, $value, 'PX', $expire]); + } + } + + /** + * @inheritDocs + */ + protected function setValues($data, $expire) + { + /** @var Connection $connection */ + $connection = \Yii::$app->getComponent($this->connectionId); + + $args = []; + foreach($data as $key => $value) { + $args[] = $key; + $args[] = $value; + } + + $failedKeys = []; + if ($expire == 0) { + $connection->executeCommand('MSET', $args); + } else { + $expire = (int) ($expire * 1000); + $connection->executeCommand('MULTI'); + $connection->executeCommand('MSET', $args); + $index = []; + foreach ($data as $key => $value) { + $connection->executeCommand('PEXPIRE', [$key, $expire]); + $index[] = $key; + } + $result = $connection->executeCommand('EXEC'); + array_shift($result); + foreach($result as $i => $r) { + if ($r != 1) { + $failedKeys[] = $index[$i]; + } + } + } + return $failedKeys; + } + + /** + * @inheritDocs + */ + protected function addValue($key, $value, $expire) + { + /** @var Connection $connection */ + $connection = \Yii::$app->getComponent($this->connectionId); + if ($expire == 0) { + return (bool) $connection->executeCommand('SET', [$key, $value, 'NX']); + } else { + $expire = (int) ($expire * 1000); + return (bool) $connection->executeCommand('SET', [$key, $value, 'PX', $expire, 'NX']); + } + } + + /** + * @inheritDocs + */ + protected function deleteValue($key) + { + /** @var Connection $connection */ + $connection = \Yii::$app->getComponent($this->connectionId); + return (bool) $connection->executeCommand('DEL', [$key]); + } + + /** + * @inheritDocs + */ + protected function flushValues() + { + /** @var Connection $connection */ + $connection = \Yii::$app->getComponent($this->connectionId); + return $connection->executeCommand('FLUSHDB'); + } +} diff --git a/extensions/redis/Connection.php b/extensions/redis/Connection.php new file mode 100644 index 0000000..cea559f --- /dev/null +++ b/extensions/redis/Connection.php @@ -0,0 +1,406 @@ + + * @since 2.0 + */ +class Connection extends Component +{ + /** + * @event Event an event that is triggered after a DB connection is established + */ + const EVENT_AFTER_OPEN = 'afterOpen'; + + /** + * @var string the hostname or ip address to use for connecting to the redis server. Defaults to 'localhost'. + */ + public $hostname = 'localhost'; + /** + * @var int the port to use for connecting to the redis server. Default port is 6379. + */ + public $port = 6379; + /** + * @var string the password for establishing DB connection. Defaults to null meaning no AUTH command is send. + * See http://redis.io/commands/auth + */ + public $password; + /** + * @var int the redis database to use. This is an integer value starting from 0. Defaults to 0. + */ + public $database = 0; + /** + * @var float timeout to use for connection to redis. If not set the timeout set in php.ini will be used: ini_get("default_socket_timeout") + */ + public $connectionTimeout = null; + /** + * @var float timeout to use for redis socket when reading and writing data. If not set the php default value will be used. + */ + public $dataTimeout = null; + + /** + * @var array List of available redis commands http://redis.io/commands + */ + public $redisCommands = [ + 'BRPOP', // key [key ...] timeout Remove and get the last element in a list, or block until one is available + 'BRPOPLPUSH', // source destination timeout Pop a value from a list, push it to another list and return it; or block until one is available + 'CLIENT KILL', // ip:port Kill the connection of a client + 'CLIENT LIST', // Get the list of client connections + 'CLIENT GETNAME', // Get the current connection name + 'CLIENT SETNAME', // connection-name Set the current connection name + 'CONFIG GET', // parameter Get the value of a configuration parameter + 'CONFIG SET', // parameter value Set a configuration parameter to the given value + 'CONFIG RESETSTAT', // Reset the stats returned by INFO + 'DBSIZE', // Return the number of keys in the selected database + 'DEBUG OBJECT', // key Get debugging information about a key + 'DEBUG SEGFAULT', // Make the server crash + 'DECR', // key Decrement the integer value of a key by one + 'DECRBY', // key decrement Decrement the integer value of a key by the given number + 'DEL', // key [key ...] Delete a key + 'DISCARD', // Discard all commands issued after MULTI + 'DUMP', // key Return a serialized version of the value stored at the specified key. + 'ECHO', // message Echo the given string + 'EVAL', // script numkeys key [key ...] arg [arg ...] Execute a Lua script server side + 'EVALSHA', // sha1 numkeys key [key ...] arg [arg ...] Execute a Lua script server side + 'EXEC', // Execute all commands issued after MULTI + 'EXISTS', // key Determine if a key exists + 'EXPIRE', // key seconds Set a key's time to live in seconds + 'EXPIREAT', // key timestamp Set the expiration for a key as a UNIX timestamp + 'FLUSHALL', // Remove all keys from all databases + 'FLUSHDB', // Remove all keys from the current database + 'GET', // key Get the value of a key + 'GETBIT', // key offset Returns the bit value at offset in the string value stored at key + 'GETRANGE', // key start end Get a substring of the string stored at a key + 'GETSET', // key value Set the string value of a key and return its old value + 'HDEL', // key field [field ...] Delete one or more hash fields + 'HEXISTS', // key field Determine if a hash field exists + 'HGET', // key field Get the value of a hash field + 'HGETALL', // key Get all the fields and values in a hash + 'HINCRBY', // key field increment Increment the integer value of a hash field by the given number + 'HINCRBYFLOAT', // key field increment Increment the float value of a hash field by the given amount + 'HKEYS', // key Get all the fields in a hash + 'HLEN', // key Get the number of fields in a hash + 'HMGET', // key field [field ...] Get the values of all the given hash fields + 'HMSET', // key field value [field value ...] Set multiple hash fields to multiple values + 'HSET', // key field value Set the string value of a hash field + 'HSETNX', // key field value Set the value of a hash field, only if the field does not exist + 'HVALS', // key Get all the values in a hash + 'INCR', // key Increment the integer value of a key by one + 'INCRBY', // key increment Increment the integer value of a key by the given amount + 'INCRBYFLOAT', // key increment Increment the float value of a key by the given amount + 'INFO', // [section] Get information and statistics about the server + 'KEYS', // pattern Find all keys matching the given pattern + 'LASTSAVE', // Get the UNIX time stamp of the last successful save to disk + 'LINDEX', // key index Get an element from a list by its index + 'LINSERT', // key BEFORE|AFTER pivot value Insert an element before or after another element in a list + 'LLEN', // key Get the length of a list + 'LPOP', // key Remove and get the first element in a list + 'LPUSH', // key value [value ...] Prepend one or multiple values to a list + 'LPUSHX', // key value Prepend a value to a list, only if the list exists + 'LRANGE', // key start stop Get a range of elements from a list + 'LREM', // key count value Remove elements from a list + 'LSET', // key index value Set the value of an element in a list by its index + 'LTRIM', // key start stop Trim a list to the specified range + 'MGET', // key [key ...] Get the values of all the given keys + 'MIGRATE', // host port key destination-db timeout Atomically transfer a key from a Redis instance to another one. + 'MONITOR', // Listen for all requests received by the server in real time + 'MOVE', // key db Move a key to another database + 'MSET', // key value [key value ...] Set multiple keys to multiple values + 'MSETNX', // key value [key value ...] Set multiple keys to multiple values, only if none of the keys exist + 'MULTI', // Mark the start of a transaction block + 'OBJECT', // subcommand [arguments [arguments ...]] Inspect the internals of Redis objects + 'PERSIST', // key Remove the expiration from a key + 'PEXPIRE', // key milliseconds Set a key's time to live in milliseconds + 'PEXPIREAT', // key milliseconds-timestamp Set the expiration for a key as a UNIX timestamp specified in milliseconds + 'PING', // Ping the server + 'PSETEX', // key milliseconds value Set the value and expiration in milliseconds of a key + 'PSUBSCRIBE', // pattern [pattern ...] Listen for messages published to channels matching the given patterns + 'PTTL', // key Get the time to live for a key in milliseconds + 'PUBLISH', // channel message Post a message to a channel + 'PUNSUBSCRIBE', // [pattern [pattern ...]] Stop listening for messages posted to channels matching the given patterns + 'QUIT', // Close the connection + 'RANDOMKEY', // Return a random key from the keyspace + 'RENAME', // key newkey Rename a key + 'RENAMENX', // key newkey Rename a key, only if the new key does not exist + 'RESTORE', // key ttl serialized-value Create a key using the provided serialized value, previously obtained using DUMP. + 'RPOP', // key Remove and get the last element in a list + 'RPOPLPUSH', // source destination Remove the last element in a list, append it to another list and return it + 'RPUSH', // key value [value ...] Append one or multiple values to a list + 'RPUSHX', // key value Append a value to a list, only if the list exists + 'SADD', // key member [member ...] Add one or more members to a set + 'SAVE', // Synchronously save the dataset to disk + 'SCARD', // key Get the number of members in a set + 'SCRIPT EXISTS', // script [script ...] Check existence of scripts in the script cache. + 'SCRIPT FLUSH', // Remove all the scripts from the script cache. + 'SCRIPT KILL', // Kill the script currently in execution. + 'SCRIPT LOAD', // script Load the specified Lua script into the script cache. + 'SDIFF', // key [key ...] Subtract multiple sets + 'SDIFFSTORE', // destination key [key ...] Subtract multiple sets and store the resulting set in a key + 'SELECT', // index Change the selected database for the current connection + 'SET', // key value Set the string value of a key + 'SETBIT', // key offset value Sets or clears the bit at offset in the string value stored at key + 'SETEX', // key seconds value Set the value and expiration of a key + 'SETNX', // key value Set the value of a key, only if the key does not exist + 'SETRANGE', // key offset value Overwrite part of a string at key starting at the specified offset + 'SHUTDOWN', // [NOSAVE] [SAVE] Synchronously save the dataset to disk and then shut down the server + 'SINTER', // key [key ...] Intersect multiple sets + 'SINTERSTORE', // destination key [key ...] Intersect multiple sets and store the resulting set in a key + 'SISMEMBER', // key member Determine if a given value is a member of a set + 'SLAVEOF', // host port Make the server a slave of another instance, or promote it as master + 'SLOWLOG', // subcommand [argument] Manages the Redis slow queries log + 'SMEMBERS', // key Get all the members in a set + 'SMOVE', // source destination member Move a member from one set to another + 'SORT', // key [BY pattern] [LIMIT offset count] [GET pattern [GET pattern ...]] [ASC|DESC] [ALPHA] [STORE destination] Sort the elements in a list, set or sorted set + 'SPOP', // key Remove and return a random member from a set + 'SRANDMEMBER', // key [count] Get one or multiple random members from a set + 'SREM', // key member [member ...] Remove one or more members from a set + 'STRLEN', // key Get the length of the value stored in a key + 'SUBSCRIBE', // channel [channel ...] Listen for messages published to the given channels + 'SUNION', // key [key ...] Add multiple sets + 'SUNIONSTORE', // destination key [key ...] Add multiple sets and store the resulting set in a key + 'SYNC', // Internal command used for replication + 'TIME', // Return the current server time + 'TTL', // key Get the time to live for a key + 'TYPE', // key Determine the type stored at key + 'UNSUBSCRIBE', // [channel [channel ...]] Stop listening for messages posted to the given channels + 'UNWATCH', // Forget about all watched keys + 'WATCH', // key [key ...] Watch the given keys to determine execution of the MULTI/EXEC block + 'ZADD', // key score member [score member ...] Add one or more members to a sorted set, or update its score if it already exists + 'ZCARD', // key Get the number of members in a sorted set + 'ZCOUNT', // key min max Count the members in a sorted set with scores within the given values + 'ZINCRBY', // key increment member Increment the score of a member in a sorted set + 'ZINTERSTORE', // destination numkeys key [key ...] [WEIGHTS weight [weight ...]] [AGGREGATE SUM|MIN|MAX] Intersect multiple sorted sets and store the resulting sorted set in a new key + 'ZRANGE', // key start stop [WITHSCORES] Return a range of members in a sorted set, by index + 'ZRANGEBYSCORE', // key min max [WITHSCORES] [LIMIT offset count] Return a range of members in a sorted set, by score + 'ZRANK', // key member Determine the index of a member in a sorted set + 'ZREM', // key member [member ...] Remove one or more members from a sorted set + 'ZREMRANGEBYRANK', // key start stop Remove all members in a sorted set within the given indexes + 'ZREMRANGEBYSCORE', // key min max Remove all members in a sorted set within the given scores + 'ZREVRANGE', // key start stop [WITHSCORES] Return a range of members in a sorted set, by index, with scores ordered from high to low + 'ZREVRANGEBYSCORE', // key max min [WITHSCORES] [LIMIT offset count] Return a range of members in a sorted set, by score, with scores ordered from high to low + 'ZREVRANK', // key member Determine the index of a member in a sorted set, with scores ordered from high to low + 'ZSCORE', // key member Get the score associated with the given member in a sorted set + 'ZUNIONSTORE', // destination numkeys key [key ...] [WEIGHTS weight [weight ...]] [AGGREGATE SUM|MIN|MAX] Add multiple sorted sets and store the resulting sorted set in a new key + ]; + /** + * @var resource redis socket connection + */ + private $_socket; + + /** + * Closes the connection when this component is being serialized. + * @return array + */ + public function __sleep() + { + $this->close(); + return array_keys(get_object_vars($this)); + } + + /** + * Returns a value indicating whether the DB connection is established. + * @return boolean whether the DB connection is established + */ + public function getIsActive() + { + return $this->_socket !== null; + } + + /** + * Establishes a DB connection. + * It does nothing if a DB connection has already been established. + * @throws Exception if connection fails + */ + public function open() + { + if ($this->_socket !== null) { + return; + } + $connection = $this->hostname . ':' . $this->port . ', database=' . $this->database; + \Yii::trace('Opening redis DB connection: ' . $connection, __CLASS__); + $this->_socket = @stream_socket_client( + 'tcp://' . $this->hostname . ':' . $this->port, + $errorNumber, + $errorDescription, + $this->connectionTimeout ? $this->connectionTimeout : ini_get("default_socket_timeout") + ); + if ($this->_socket) { + if ($this->dataTimeout !== null) { + stream_set_timeout($this->_socket, $timeout=(int)$this->dataTimeout, (int) (($this->dataTimeout - $timeout) * 1000000)); + } + if ($this->password !== null) { + $this->executeCommand('AUTH', [$this->password]); + } + $this->executeCommand('SELECT', [$this->database]); + $this->initConnection(); + } else { + \Yii::error("Failed to open DB connection ($connection): " . $errorNumber . ' - ' . $errorDescription, __CLASS__); + $message = YII_DEBUG ? 'Failed to open DB connection: ' . $errorNumber . ' - ' . $errorDescription : 'Failed to open DB connection.'; + throw new Exception($message, $errorDescription, (int)$errorNumber); + } + } + + /** + * Closes the currently active DB connection. + * It does nothing if the connection is already closed. + */ + public function close() + { + if ($this->_socket !== null) { + $connection = $this->hostname . ':' . $this->port . ', database=' . $this->database; + \Yii::trace('Closing DB connection: ' . $connection, __CLASS__); + $this->executeCommand('QUIT'); + stream_socket_shutdown($this->_socket, STREAM_SHUT_RDWR); + $this->_socket = null; + } + } + + /** + * Initializes the DB connection. + * This method is invoked right after the DB connection is established. + * The default implementation triggers an [[EVENT_AFTER_OPEN]] event. + */ + protected function initConnection() + { + $this->trigger(self::EVENT_AFTER_OPEN); + } + + /** + * Returns the name of the DB driver for the current [[dsn]]. + * @return string name of the DB driver + */ + public function getDriverName() + { + return 'redis'; + } + + /** + * @return LuaScriptBuilder + */ + public function getLuaScriptBuilder() + { + return new LuaScriptBuilder(); + } + + /** + * + * @param string $name + * @param array $params + * @return mixed + */ + public function __call($name, $params) + { + $redisCommand = strtoupper(Inflector::camel2words($name, false)); + if (in_array($redisCommand, $this->redisCommands)) { + return $this->executeCommand($name, $params); + } else { + return parent::__call($name, $params); + } + } + + /** + * Executes a redis command. + * For a list of available commands and their parameters see http://redis.io/commands. + * + * @param string $name the name of the command + * @param array $params list of parameters for the command + * @return array|bool|null|string Dependend on the executed command this method + * will return different data types: + * + * - `true` for commands that return "status reply". + * - `string` for commands that return "integer reply" + * as the value is in the range of a signed 64 bit integer. + * - `string` or `null` for commands that return "bulk reply". + * - `array` for commands that return "Multi-bulk replies". + * + * See [redis protocol description](http://redis.io/topics/protocol) + * for details on the mentioned reply types. + * @trows Exception for commands that return [error reply](http://redis.io/topics/protocol#error-reply). + */ + public function executeCommand($name, $params=[]) + { + $this->open(); + + array_unshift($params, $name); + $command = '*' . count($params) . "\r\n"; + foreach($params as $arg) { + $command .= '$' . mb_strlen($arg, '8bit') . "\r\n" . $arg . "\r\n"; + } + + \Yii::trace("Executing Redis Command: {$name}", __CLASS__); + fwrite($this->_socket, $command); + + return $this->parseResponse(implode(' ', $params)); + } + + private function parseResponse($command) + { + if (($line = fgets($this->_socket)) === false) { + throw new Exception("Failed to read from socket.\nRedis command was: " . $command); + } + $type = $line[0]; + $line = mb_substr($line, 1, -2, '8bit'); + switch($type) + { + case '+': // Status reply + return true; + case '-': // Error reply + throw new Exception("Redis error: " . $line . "\nRedis command was: " . $command); + case ':': // Integer reply + // no cast to int as it is in the range of a signed 64 bit integer + return $line; + case '$': // Bulk replies + if ($line == '-1') { + return null; + } + $length = $line + 2; + $data = ''; + while ($length > 0) { + if (($block = fread($this->_socket, $line + 2)) === false) { + throw new Exception("Failed to read from socket.\nRedis command was: " . $command); + } + $data .= $block; + $length -= mb_strlen($block, '8bit'); + } + return mb_substr($data, 0, -2, '8bit'); + case '*': // Multi-bulk replies + $count = (int) $line; + $data = []; + for ($i = 0; $i < $count; $i++) { + $data[] = $this->parseResponse($command); + } + return $data; + default: + throw new Exception('Received illegal data from redis: ' . $line . "\nRedis command was: " . $command); + } + } +} diff --git a/extensions/redis/LICENSE.md b/extensions/redis/LICENSE.md new file mode 100644 index 0000000..e98f03d --- /dev/null +++ b/extensions/redis/LICENSE.md @@ -0,0 +1,32 @@ +The Yii framework is free software. It is released under the terms of +the following BSD License. + +Copyright © 2008 by Yii Software LLC (http://www.yiisoft.com) +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + * Neither the name of Yii Software LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff --git a/extensions/redis/LuaScriptBuilder.php b/extensions/redis/LuaScriptBuilder.php new file mode 100644 index 0000000..81dff3f --- /dev/null +++ b/extensions/redis/LuaScriptBuilder.php @@ -0,0 +1,365 @@ + + * @since 2.0 + */ +class LuaScriptBuilder extends \yii\base\Object +{ + /** + * Builds a Lua script for finding a list of records + * @param ActiveQuery $query the query used to build the script + * @return string + */ + public function buildAll($query) + { + // TODO add support for orderBy + /** @var ActiveRecord $modelClass */ + $modelClass = $query->modelClass; + $key = $this->quoteValue($modelClass::tableName() . ':a:'); + return $this->build($query, "n=n+1 pks[n]=redis.call('HGETALL',$key .. pk)", 'pks'); + } + + /** + * Builds a Lua script for finding one record + * @param ActiveQuery $query the query used to build the script + * @return string + */ + public function buildOne($query) + { + // TODO add support for orderBy + /** @var ActiveRecord $modelClass */ + $modelClass = $query->modelClass; + $key = $this->quoteValue($modelClass::tableName() . ':a:'); + return $this->build($query, "do return redis.call('HGETALL',$key .. pk) end", 'pks'); + } + + /** + * Builds a Lua script for finding a column + * @param ActiveQuery $query the query used to build the script + * @param string $column name of the column + * @return string + */ + public function buildColumn($query, $column) + { + // TODO add support for orderBy and indexBy + /** @var ActiveRecord $modelClass */ + $modelClass = $query->modelClass; + $key = $this->quoteValue($modelClass::tableName() . ':a:'); + return $this->build($query, "n=n+1 pks[n]=redis.call('HGET',$key .. pk," . $this->quoteValue($column) . ")", 'pks'); + } + + /** + * Builds a Lua script for getting count of records + * @param ActiveQuery $query the query used to build the script + * @return string + */ + public function buildCount($query) + { + return $this->build($query, 'n=n+1', 'n'); + } + + /** + * Builds a Lua script for finding the sum of a column + * @param ActiveQuery $query the query used to build the script + * @param string $column name of the column + * @return string + */ + public function buildSum($query, $column) + { + /** @var ActiveRecord $modelClass */ + $modelClass = $query->modelClass; + $key = $this->quoteValue($modelClass::tableName() . ':a:'); + return $this->build($query, "n=n+redis.call('HGET',$key .. pk," . $this->quoteValue($column) . ")", 'n'); + } + + /** + * Builds a Lua script for finding the average of a column + * @param ActiveQuery $query the query used to build the script + * @param string $column name of the column + * @return string + */ + public function buildAverage($query, $column) + { + /** @var ActiveRecord $modelClass */ + $modelClass = $query->modelClass; + $key = $this->quoteValue($modelClass::tableName() . ':a:'); + return $this->build($query, "n=n+1 if v==nil then v=0 end v=v+redis.call('HGET',$key .. pk," . $this->quoteValue($column) . ")", 'v/n'); + } + + /** + * Builds a Lua script for finding the min value of a column + * @param ActiveQuery $query the query used to build the script + * @param string $column name of the column + * @return string + */ + public function buildMin($query, $column) + { + /** @var ActiveRecord $modelClass */ + $modelClass = $query->modelClass; + $key = $this->quoteValue($modelClass::tableName() . ':a:'); + return $this->build($query, "n=redis.call('HGET',$key .. pk," . $this->quoteValue($column) . ") if v==nil or nmodelClass; + $key = $this->quoteValue($modelClass::tableName() . ':a:'); + return $this->build($query, "n=redis.call('HGET',$key .. pk," . $this->quoteValue($column) . ") if v==nil or n>v then v=n end", 'v'); + } + + /** + * @param ActiveQuery $query the query used to build the script + * @param string $buildResult the lua script for building the result + * @param string $return the lua variable that should be returned + * @return string + */ + private function build($query, $buildResult, $return) + { + if (!empty($query->orderBy)) { + throw new NotSupportedException('orderBy is currently not supported by redis ActiveRecord.'); + } + + $columns = []; + if ($query->where !== null) { + $condition = $this->buildCondition($query->where, $columns); + } else { + $condition = 'true'; + } + + $start = $query->offset === null ? 0 : $query->offset; + $limitCondition = 'i>' . $start . ($query->limit === null ? '' : ' and i<=' . ($start + $query->limit)); + + /** @var ActiveRecord $modelClass */ + $modelClass = $query->modelClass; + $key = $this->quoteValue($modelClass::tableName()); + $loadColumnValues = ''; + foreach($columns as $column => $alias) { + $loadColumnValues .= "local $alias=redis.call('HGET',$key .. ':a:' .. pk, '$column')\n"; + } + + return << 'buildAndCondition', + 'or' => 'buildAndCondition', + 'between' => 'buildBetweenCondition', + 'not between' => 'buildBetweenCondition', + 'in' => 'buildInCondition', + 'not in' => 'buildInCondition', + 'like' => 'buildLikeCondition', + 'not like' => 'buildLikeCondition', + 'or like' => 'buildLikeCondition', + 'or not like' => 'buildLikeCondition', + ]; + + if (!is_array($condition)) { + throw new NotSupportedException('Where condition must be an array in redis ActiveRecord.'); + } + if (isset($condition[0])) { // operator format: operator, operand 1, operand 2, ... + $operator = strtolower($condition[0]); + if (isset($builders[$operator])) { + $method = $builders[$operator]; + array_shift($condition); + return $this->$method($operator, $condition, $columns); + } else { + throw new Exception('Found unknown operator in query: ' . $operator); + } + } else { // hash format: 'column1' => 'value1', 'column2' => 'value2', ... + return $this->buildHashCondition($condition, $columns); + } + } + + private function buildHashCondition($condition, &$columns) + { + $parts = []; + foreach ($condition as $column => $value) { + if (is_array($value)) { // IN condition + $parts[] = $this->buildInCondition('in', [$column, $value], $columns); + } else { + $column = $this->addColumn($column, $columns); + if ($value === null) { + $parts[] = "$column==nil"; + } elseif ($value instanceof Expression) { + $parts[] = "$column==" . $value->expression; + } else { + $value = $this->quoteValue($value); + $parts[] = "$column==$value"; + } + } + } + return count($parts) === 1 ? $parts[0] : '(' . implode(') and (', $parts) . ')'; + } + + private function buildAndCondition($operator, $operands, &$columns) + { + $parts = []; + foreach ($operands as $operand) { + if (is_array($operand)) { + $operand = $this->buildCondition($operand, $columns); + } + if ($operand !== '') { + $parts[] = $operand; + } + } + if (!empty($parts)) { + return '(' . implode(") $operator (", $parts) . ')'; + } else { + return ''; + } + } + + private function buildBetweenCondition($operator, $operands, &$columns) + { + if (!isset($operands[0], $operands[1], $operands[2])) { + throw new Exception("Operator '$operator' requires three operands."); + } + + list($column, $value1, $value2) = $operands; + + $value1 = $this->quoteValue($value1); + $value2 = $this->quoteValue($value2); + $column = $this->addColumn($column, $columns); + return "$column >= $value1 and $column <= $value2"; + } + + private function buildInCondition($operator, $operands, &$columns) + { + if (!isset($operands[0], $operands[1])) { + throw new Exception("Operator '$operator' requires two operands."); + } + + list($column, $values) = $operands; + + $values = (array)$values; + + if (empty($values) || $column === []) { + return $operator === 'in' ? 'false' : 'true'; + } + + if (count($column) > 1) { + return $this->buildCompositeInCondition($operator, $column, $values, $columns); + } elseif (is_array($column)) { + $column = reset($column); + } + $columnAlias = $this->addColumn($column, $columns); + $parts = []; + foreach ($values as $i => $value) { + if (is_array($value)) { + $value = isset($value[$column]) ? $value[$column] : null; + } + if ($value === null) { + $parts[] = "$columnAlias==nil"; + } elseif ($value instanceof Expression) { + $parts[] = "$columnAlias==" . $value->expression; + } else { + $value = $this->quoteValue($value); + $parts[] = "$columnAlias==$value"; + } + } + $operator = $operator === 'in' ? '' : 'not '; + return "$operator(" . implode(' or ', $parts) . ')'; + } + + protected function buildCompositeInCondition($operator, $inColumns, $values, &$columns) + { + $vss = []; + foreach ($values as $value) { + $vs = []; + foreach ($inColumns as $column) { + $column = $this->addColumn($column, $columns); + if (isset($value[$column])) { + $vs[] = "$column==" . $this->quoteValue($value[$column]); + } else { + $vs[] = "$column==nil"; + } + } + $vss[] = '(' . implode(' and ', $vs) . ')'; + } + $operator = $operator === 'in' ? '' : 'not '; + return "$operator(" . implode(' or ', $vss) . ')'; + } + + private function buildLikeCondition($operator, $operands, &$columns) + { + throw new NotSupportedException('LIKE conditions are not suppoerted by redis ActiveRecord.'); + } +} diff --git a/extensions/redis/README.md b/extensions/redis/README.md new file mode 100644 index 0000000..28cecf1 --- /dev/null +++ b/extensions/redis/README.md @@ -0,0 +1,91 @@ +Redis Cache and ActiveRecord for Yii 2 +====================================== + +This extension provides the [redis](http://redis.io/) key-value store support for the Yii2 framework. +It includes a `Cache` class and implents the `ActiveRecord` pattern that allows you to store active +records in redis. + +To use this extension, you have to configure the Connection class in your application configuration: + +```php +return [ + //.... + 'components' => [ + 'redis' => [ + 'class' => 'yii\redis\Connection', + 'hostname' => 'localhost', + 'port' => 6379, + 'database' => 0, + ], + ] +]; +``` + +To use the `Cache` component, you also have to configure the `cache` component to be `yii\redis\Cache`: + +```php +return [ + //.... + 'components' => [ + // ... + 'cache' => [ + 'class' => 'yii\redis\Cache', + ], + ] +]; +``` + + +Installation +------------ + +The preferred way to install this extension is through [composer](http://getcomposer.org/download/). + +Either run + +``` +php composer.phar require yiisoft/yii2-redis "*" +``` + +or add + +```json +"yiisoft/yii2-redis": "*" +``` + +to the require section of your composer.json. + + +Using the redis ActiveRecord +---------------------------- + +For general information on how to use yii's ActiveRecord please refer to the [guide](https://github.com/yiisoft/yii2/blob/master/docs/guide/active-record.md). + +For defining a redis ActiveRecord class your record class needs to extend from `yii\redis\ActiveRecord` and +implement at least the `attributes()` method to define the attributes of the record. +A primary key can be defined via [[primaryKey()]] which defaults to `id` if not specified. +The primaryKey needs to be part of the attributes so make sure you have an `id` attribute defined if you do +not specify your own primary key. + +The following is an example model called `Customer`: + +```php +class Customer extends \yii\redis\ActiveRecord +{ + public function attributes() + { + return ['id', 'name', 'address', 'registration_date']; + } +} +``` + +The general usage of redis ActiveRecord is very similar to the database ActiveRecord as described in the +[guide](https://github.com/yiisoft/yii2/blob/master/docs/guide/active-record.md). +It supports the same interface and features except the following limitations: + +- As redis does not support SQL the query API is limited to the following methods: + `where()`, `limit()`, `offset()`, `orderBy()` and `indexBy()`. + (orderBy() is not yet implemented: [#1305](https://github.com/yiisoft/yii2/issues/1305)) +- `via`-relations can not be defined via a table as there are not tables in redis. You can only define relations via other records. + +It is also possible to define relations from redis ActiveRecords to normal ActiveRecord classes and vice versa. \ No newline at end of file diff --git a/extensions/redis/composer.json b/extensions/redis/composer.json new file mode 100644 index 0000000..fb5065b --- /dev/null +++ b/extensions/redis/composer.json @@ -0,0 +1,27 @@ +{ + "name": "yiisoft/yii2-redis", + "description": "Redis Cache and ActiveRecord for the Yii framework", + "keywords": ["yii", "redis", "active-record", "cache"], + "type": "yii2-extension", + "license": "BSD-3-Clause", + "support": { + "issues": "https://github.com/yiisoft/yii2/issues?labels=ext%3Aredis", + "forum": "http://www.yiiframework.com/forum/", + "wiki": "http://www.yiiframework.com/wiki/", + "irc": "irc://irc.freenode.net/yii", + "source": "https://github.com/yiisoft/yii2" + }, + "authors": [ + { + "name": "Carsten Brandt", + "email": "mail@cebe.cc" + } + ], + "require": { + "yiisoft/yii2": "*" + }, + "autoload": { + "psr-0": { "yii\\redis\\": "" } + }, + "target-dir": "yii/redis" +} diff --git a/extensions/sphinx/composer.json b/extensions/sphinx/composer.json index 0331667..decac2f 100644 --- a/extensions/sphinx/composer.json +++ b/extensions/sphinx/composer.json @@ -1,7 +1,7 @@ { "name": "yiisoft/yii2-sphinx", "description": "Sphinx full text search engine extension for the Yii framework", - "keywords": ["yii", "sphinx", "search", "fulltext"], + "keywords": ["yii", "sphinx", "active-record", "search", "fulltext"], "type": "yii2-extension", "license": "BSD-3-Clause", "support": { diff --git a/framework/yii/caching/RedisCache.php b/framework/yii/caching/RedisCache.php deleted file mode 100644 index b64f000..0000000 --- a/framework/yii/caching/RedisCache.php +++ /dev/null @@ -1,218 +0,0 @@ - [ - * 'cache' => [ - * 'class' => 'RedisCache', - * 'hostname' => 'localhost', - * 'port' => 6379, - * 'database' => 0, - * ], - * ], - * ] - * ~~~ - * - * @property Connection $connection The redis connection object. This property is read-only. - * - * @author Carsten Brandt - * @since 2.0 - */ -class RedisCache extends Cache -{ - /** - * @var string hostname to use for connecting to the redis server. Defaults to 'localhost'. - */ - public $hostname = 'localhost'; - /** - * @var int the port to use for connecting to the redis server. Default port is 6379. - */ - public $port = 6379; - /** - * @var string the password to use to authenticate with the redis server. If not set, no AUTH command will be sent. - */ - public $password; - /** - * @var int the redis database to use. This is an integer value starting from 0. Defaults to 0. - */ - public $database = 0; - /** - * @var float timeout to use for connection to redis. If not set the timeout set in php.ini will be used: ini_get("default_socket_timeout") - */ - public $connectionTimeout = null; - /** - * @var float timeout to use for redis socket when reading and writing data. If not set the php default value will be used. - */ - public $dataTimeout = null; - /** - * @var Connection the redis connection - */ - private $_connection; - - - /** - * Initializes the cache component by establishing a connection to the redis server. - */ - public function init() - { - parent::init(); - $this->getConnection(); - } - - /** - * Returns the redis connection object. - * Establishes a connection to the redis server if it does not already exists. - * @return Connection the redis connection object. - */ - public function getConnection() - { - if ($this->_connection === null) { - $this->_connection = new Connection([ - 'dsn' => 'redis://' . $this->hostname . ':' . $this->port . '/' . $this->database, - 'password' => $this->password, - 'connectionTimeout' => $this->connectionTimeout, - 'dataTimeout' => $this->dataTimeout, - ]); - } - return $this->_connection; - } - - /** - * Checks whether a specified key exists in the cache. - * This can be faster than getting the value from the cache if the data is big. - * Note that this method does not check whether the dependency associated - * with the cached data, if there is any, has changed. So a call to [[get]] - * may return false while exists returns true. - * @param mixed $key a key identifying the cached value. This can be a simple string or - * a complex data structure consisting of factors representing the key. - * @return boolean true if a value exists in cache, false if the value is not in the cache or expired. - */ - public function exists($key) - { - return (bool) $this->_connection->executeCommand('EXISTS', [$this->buildKey($key)]); - } - - /** - * @inheritDocs - */ - protected function getValue($key) - { - return $this->_connection->executeCommand('GET', [$key]); - } - - /** - * @inheritDocs - */ - protected function getValues($keys) - { - $response = $this->_connection->executeCommand('MGET', $keys); - $result = []; - $i = 0; - foreach ($keys as $key) { - $result[$key] = $response[$i++]; - } - return $result; - } - - /** - * @inheritDocs - */ - protected function setValue($key, $value, $expire) - { - if ($expire == 0) { - return (bool) $this->_connection->executeCommand('SET', [$key, $value]); - } else { - $expire = (int) ($expire * 1000); - return (bool) $this->_connection->executeCommand('SET', [$key, $value, 'PX', $expire]); - } - } - - /** - * @inheritDocs - */ - protected function setValues($data, $expire) - { - $args = []; - foreach($data as $key => $value) { - $args[] = $key; - $args[] = $value; - } - - $failedKeys = []; - if ($expire == 0) { - $this->_connection->executeCommand('MSET', $args); - } else { - $expire = (int) ($expire * 1000); - $this->_connection->executeCommand('MULTI'); - $this->_connection->executeCommand('MSET', $args); - $index = []; - foreach ($data as $key => $value) { - $this->_connection->executeCommand('PEXPIRE', [$key, $expire]); - $index[] = $key; - } - $result = $this->_connection->executeCommand('EXEC'); - array_shift($result); - foreach($result as $i => $r) { - if ($r != 1) { - $failedKeys[] = $index[$i]; - } - } - } - return $failedKeys; - } - - /** - * @inheritDocs - */ - protected function addValue($key, $value, $expire) - { - if ($expire == 0) { - return (bool) $this->_connection->executeCommand('SET', [$key, $value, 'NX']); - } else { - $expire = (int) ($expire * 1000); - return (bool) $this->_connection->executeCommand('SET', [$key, $value, 'PX', $expire, 'NX']); - } - } - - /** - * @inheritDocs - */ - protected function deleteValue($key) - { - return (bool) $this->_connection->executeCommand('DEL', [$key]); - } - - /** - * @inheritDocs - */ - protected function flushValues() - { - return $this->_connection->executeCommand('FLUSHDB'); - } -} diff --git a/framework/yii/classes.php b/framework/yii/classes.php index 9f39ee9..c8b2935 100644 --- a/framework/yii/classes.php +++ b/framework/yii/classes.php @@ -61,7 +61,6 @@ return [ 'yii\caching\GroupDependency' => YII_PATH . '/caching/GroupDependency.php', 'yii\caching\MemCache' => YII_PATH . '/caching/MemCache.php', 'yii\caching\MemCacheServer' => YII_PATH . '/caching/MemCacheServer.php', - 'yii\caching\RedisCache' => YII_PATH . '/caching/RedisCache.php', 'yii\caching\WinCache' => YII_PATH . '/caching/WinCache.php', 'yii\caching\XCache' => YII_PATH . '/caching/XCache.php', 'yii\caching\ZendDataCache' => YII_PATH . '/caching/ZendDataCache.php', @@ -168,8 +167,6 @@ return [ 'yii\rbac\Item' => YII_PATH . '/rbac/Item.php', 'yii\rbac\Manager' => YII_PATH . '/rbac/Manager.php', 'yii\rbac\PhpManager' => YII_PATH . '/rbac/PhpManager.php', - 'yii\redis\Connection' => YII_PATH . '/redis/Connection.php', - 'yii\redis\Transaction' => YII_PATH . '/redis/Transaction.php', 'yii\requirements\YiiRequirementChecker' => YII_PATH . '/requirements/YiiRequirementChecker.php', 'yii\test\DbFixtureManager' => YII_PATH . '/test/DbFixtureManager.php', 'yii\test\DbTestTrait' => YII_PATH . '/test/DbTestTrait.php', diff --git a/framework/yii/redis/ActiveQuery.php b/framework/yii/redis/ActiveQuery.php deleted file mode 100644 index 2174901..0000000 --- a/framework/yii/redis/ActiveQuery.php +++ /dev/null @@ -1,382 +0,0 @@ -with('orders')->asArray()->all(); - * ~~~ - * - * @author Carsten Brandt - * @since 2.0 - */ -class ActiveQuery extends \yii\base\Component implements ActiveQueryInterface -{ - use QueryTrait; - use ActiveQueryTrait; - - /** - * Executes the query and returns all results as an array. - * @param Connection $db the database connection used to execute the query. - * If this parameter is not given, the `db` application component will be used. - * @return array|ActiveRecord[] the query results. If the query results in nothing, an empty array will be returned. - */ - public function all($db = null) - { - // TODO add support for orderBy - $data = $this->executeScript($db, 'All'); - $rows = []; - foreach($data as $dataRow) { - $row = []; - $c = count($dataRow); - for($i = 0; $i < $c; ) { - $row[$dataRow[$i++]] = $dataRow[$i++]; - } - $rows[] = $row; - } - if (!empty($rows)) { - $models = $this->createModels($rows); - if (!empty($this->with)) { - $this->findWith($this->with, $models); - } - return $models; - } else { - return []; - } - } - - /** - * Executes the query and returns a single row of result. - * @param Connection $db the database connection used to execute the query. - * If this parameter is not given, the `db` application component will be used. - * @return ActiveRecord|array|null a single row of query result. Depending on the setting of [[asArray]], - * the query result may be either an array or an ActiveRecord object. Null will be returned - * if the query results in nothing. - */ - public function one($db = null) - { - // TODO add support for orderBy - $data = $this->executeScript($db, 'One'); - if (empty($data)) { - return null; - } - $row = []; - $c = count($data); - for($i = 0; $i < $c; ) { - $row[$data[$i++]] = $data[$i++]; - } - if ($this->asArray) { - $model = $row; - } else { - /** @var ActiveRecord $class */ - $class = $this->modelClass; - $model = $class::create($row); - } - if (!empty($this->with)) { - $models = [$model]; - $this->findWith($this->with, $models); - $model = $models[0]; - } - return $model; - } - - /** - * Returns the number of records. - * @param string $q the COUNT expression. This parameter is ignored by this implementation. - * @param Connection $db the database connection used to execute the query. - * If this parameter is not given, the `db` application component will be used. - * @return integer number of records - */ - public function count($q = '*', $db = null) - { - if ($this->offset === null && $this->limit === null && $this->where === null) { - /** @var ActiveRecord $modelClass */ - $modelClass = $this->modelClass; - if ($db === null) { - $db = $modelClass::getDb(); - } - return $db->executeCommand('LLEN', [$modelClass::tableName()]); - } else { - return $this->executeScript($db, 'Count'); - } - } - - /** - * Returns a value indicating whether the query result contains any row of data. - * @param Connection $db the database connection used to execute the query. - * If this parameter is not given, the `db` application component will be used. - * @return boolean whether the query result contains any row of data. - */ - public function exists($db = null) - { - return $this->one($db) !== null; - } - - /** - * Executes the query and returns the first column of the result. - * @param string $column name of the column to select - * @param Connection $db the database connection used to execute the query. - * If this parameter is not given, the `db` application component will be used. - * @return array the first column of the query result. An empty array is returned if the query results in nothing. - */ - public function column($column, $db = null) - { - // TODO add support for orderBy - return $this->executeScript($db, 'Column', $column); - } - - /** - * Returns the number of records. - * @param string $column the column to sum up - * @param Connection $db the database connection used to execute the query. - * If this parameter is not given, the `db` application component will be used. - * @return integer number of records - */ - public function sum($column, $db = null) - { - return $this->executeScript($db, 'Sum', $column); - } - - /** - * Returns the average of the specified column values. - * @param string $column the column name or expression. - * Make sure you properly quote column names in the expression. - * @param Connection $db the database connection used to execute the query. - * If this parameter is not given, the `db` application component will be used. - * @return integer the average of the specified column values. - */ - public function average($column, $db = null) - { - return $this->executeScript($db, 'Average', $column); - } - - /** - * Returns the minimum of the specified column values. - * @param string $column the column name or expression. - * Make sure you properly quote column names in the expression. - * @param Connection $db the database connection used to execute the query. - * If this parameter is not given, the `db` application component will be used. - * @return integer the minimum of the specified column values. - */ - public function min($column, $db = null) - { - return $this->executeScript($db, 'Min', $column); - } - - /** - * Returns the maximum of the specified column values. - * @param string $column the column name or expression. - * Make sure you properly quote column names in the expression. - * @param Connection $db the database connection used to execute the query. - * If this parameter is not given, the `db` application component will be used. - * @return integer the maximum of the specified column values. - */ - public function max($column, $db = null) - { - return $this->executeScript($db, 'Max', $column); - } - - /** - * Returns the query result as a scalar value. - * The value returned will be the specified attribute in the first record of the query results. - * @param string $attribute name of the attribute to select - * @param Connection $db the database connection used to execute the query. - * If this parameter is not given, the `db` application component will be used. - * @return string the value of the specified attribute in the first record of the query result. - * Null is returned if the query result is empty. - */ - public function scalar($attribute, $db = null) - { - $record = $this->one($db); - if ($record !== null) { - return $record->$attribute; - } else { - return null; - } - } - - - /** - * Executes a script created by [[LuaScriptBuilder]] - * @param Connection $db the database connection used to execute the query. - * If this parameter is not given, the `db` application component will be used. - * @param string $type the type of the script to generate - * @param string $columnName - * @return array|bool|null|string - */ - protected function executeScript($db, $type, $columnName = null) - { - if (!empty($this->orderBy)) { - throw new NotSupportedException('orderBy is currently not supported by redis ActiveRecord.'); - } - - /** @var ActiveRecord $modelClass */ - $modelClass = $this->modelClass; - - if ($db === null) { - $db = $modelClass::getDb(); - } - - // find by primary key if possible. This is much faster than scanning all records - if (is_array($this->where) && !isset($this->where[0]) && $modelClass::isPrimaryKey(array_keys($this->where))) { - return $this->findByPk($db, $type, $columnName); - } - - $method = 'build' . $type; - $script = $db->getLuaScriptBuilder()->$method($this, $columnName); - return $db->executeCommand('EVAL', [$script, 0]); - } - - /** - * Fetch by pk if possible as this is much faster - * @param Connection $db the database connection used to execute the query. - * If this parameter is not given, the `db` application component will be used. - * @param string $type the type of the script to generate - * @param string $columnName - * @return array|bool|null|string - * @throws \yii\base\InvalidParamException - * @throws \yii\base\NotSupportedException - */ - private function findByPk($db, $type, $columnName = null) - { - if (count($this->where) == 1) { - $pks = (array) reset($this->where); - } else { - foreach($this->where as $column => $values) { - if (is_array($values)) { - // TODO support composite IN for composite PK - throw new NotSupportedException('Find by composite PK is not supported by redis ActiveRecord.'); - } - } - $pks = [$this->where]; - } - - /** @var ActiveRecord $modelClass */ - $modelClass = $this->modelClass; - - $start = $this->offset === null ? 0 : $this->offset; - $i = 0; - $data = []; - foreach($pks as $pk) { - if (++$i > $start && ($this->limit === null || $i <= $start + $this->limit)) { - $key = $modelClass::tableName() . ':a:' . $modelClass::buildKey($pk); - $result = $db->executeCommand('HGETALL', [$key]); - if (!empty($result)) { - $data[] = $result; - if ($type === 'One' && $this->orderBy === null) { - break; - } - } - } - } - // TODO support orderBy - - switch($type) { - case 'All': - return $data; - case 'One': - return reset($data); - case 'Count': - return count($data); - case 'Column': - $column = []; - foreach($data as $dataRow) { - $row = []; - $c = count($dataRow); - for($i = 0; $i < $c; ) { - $row[$dataRow[$i++]] = $dataRow[$i++]; - } - $column[] = $row[$columnName]; - } - return $column; - case 'Sum': - $sum = 0; - foreach($data as $dataRow) { - $c = count($dataRow); - for($i = 0; $i < $c; ) { - if ($dataRow[$i++] == $columnName) { - $sum += $dataRow[$i]; - break; - } - } - } - return $sum; - case 'Average': - $sum = 0; - $count = 0; - foreach($data as $dataRow) { - $count++; - $c = count($dataRow); - for($i = 0; $i < $c; ) { - if ($dataRow[$i++] == $columnName) { - $sum += $dataRow[$i]; - break; - } - } - } - return $sum / $count; - case 'Min': - $min = null; - foreach($data as $dataRow) { - $c = count($dataRow); - for($i = 0; $i < $c; ) { - if ($dataRow[$i++] == $columnName && ($min == null || $dataRow[$i] < $min)) { - $min = $dataRow[$i]; - break; - } - } - } - return $min; - case 'Max': - $max = null; - foreach($data as $dataRow) { - $c = count($dataRow); - for($i = 0; $i < $c; ) { - if ($dataRow[$i++] == $columnName && ($max == null || $dataRow[$i] > $max)) { - $max = $dataRow[$i]; - break; - } - } - } - return $max; - } - throw new InvalidParamException('Unknown fetch type: ' . $type); - } -} diff --git a/framework/yii/redis/ActiveRecord.php b/framework/yii/redis/ActiveRecord.php deleted file mode 100644 index 46132fc..0000000 --- a/framework/yii/redis/ActiveRecord.php +++ /dev/null @@ -1,322 +0,0 @@ - - * @since 2.0 - */ -class ActiveRecord extends \yii\db\ActiveRecord -{ - /** - * Returns the database connection used by this AR class. - * By default, the "redis" application component is used as the database connection. - * You may override this method if you want to use a different database connection. - * @return Connection the database connection used by this AR class. - */ - public static function getDb() - { - return \Yii::$app->getComponent('redis'); - } - - /** - * @inheritDoc - */ - public static function createQuery() - { - return new ActiveQuery(['modelClass' => get_called_class()]); - } - - /** - * @inheritDoc - */ - public static function createActiveRelation($config = []) - { - return new ActiveRelation($config); - } - - /** - * Returns the primary key name(s) for this AR class. - * This method should be overridden by child classes to define the primary key. - * - * Note that an array should be returned even when it is a single primary key. - * - * @return string[] the primary keys of this record. - */ - public static function primaryKey() - { - return ['id']; - } - - /** - * Returns the list of all attribute names of the model. - * This method must be overridden by child classes to define available attributes. - * @return array list of attribute names. - */ - public static function attributes() - { - throw new InvalidConfigException('The attributes() method of redis ActiveRecord has to be implemented by child classes.'); - } - - /** - * @inheritDocs - */ - public function insert($runValidation = true, $attributes = null) - { - if ($runValidation && !$this->validate($attributes)) { - return false; - } - if ($this->beforeSave(true)) { - $db = static::getDb(); - $values = $this->getDirtyAttributes($attributes); - $pk = []; -// if ($values === []) { - foreach ($this->primaryKey() as $key) { - $pk[$key] = $values[$key] = $this->getAttribute($key); - if ($pk[$key] === null) { - $pk[$key] = $values[$key] = $db->executeCommand('INCR', [static::tableName() . ':s:' . $key]); - $this->setAttribute($key, $values[$key]); - } - } -// } - // save pk in a findall pool - $db->executeCommand('RPUSH', [static::tableName(), static::buildKey($pk)]); - - $key = static::tableName() . ':a:' . static::buildKey($pk); - // save attributes - $args = [$key]; - foreach($values as $attribute => $value) { - $args[] = $attribute; - $args[] = $value; - } - $db->executeCommand('HMSET', $args); - - $this->setOldAttributes($values); - $this->afterSave(true); - return true; - } - return false; - } - - /** - * Updates the whole table 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], ['id' => 2]); - * ~~~ - * - * @param array $attributes attribute values (name-value pairs) to be saved into the table - * @param array $condition the conditions that will be put in the WHERE part of the UPDATE SQL. - * Please refer to [[ActiveQuery::where()]] on how to specify this parameter. - * @param array $params this parameter is ignored in redis implementation. - * @return integer the number of rows updated - */ - public static function updateAll($attributes, $condition = null, $params = []) - { - if (empty($attributes)) { - return 0; - } - $db = static::getDb(); - $n=0; - foreach(static::fetchPks($condition) as $pk) { - $newPk = $pk; - $pk = static::buildKey($pk); - $key = static::tableName() . ':a:' . $pk; - // save attributes - $args = [$key]; - foreach($attributes as $attribute => $value) { - if (isset($newPk[$attribute])) { - $newPk[$attribute] = $value; - } - $args[] = $attribute; - $args[] = $value; - } - $newPk = static::buildKey($newPk); - $newKey = static::tableName() . ':a:' . $newPk; - // rename index if pk changed - if ($newPk != $pk) { - $db->executeCommand('MULTI'); - $db->executeCommand('HMSET', $args); - $db->executeCommand('LINSERT', [static::tableName(), 'AFTER', $pk, $newPk]); - $db->executeCommand('LREM', [static::tableName(), 0, $pk]); - $db->executeCommand('RENAME', [$key, $newKey]); - $db->executeCommand('EXEC'); - } else { - $db->executeCommand('HMSET', $args); - } - $n++; - } - return $n; - } - - /** - * Updates the whole table using the provided counter changes and conditions. - * For example, to increment all customers' age by 1, - * - * ~~~ - * Customer::updateAllCounters(['age' => 1]); - * ~~~ - * - * @param array $counters the counters to be updated (attribute name => increment value). - * Use negative values if you want to decrement the counters. - * @param array $condition the conditions that will be put in the WHERE part of the UPDATE SQL. - * Please refer to [[ActiveQuery::where()]] on how to specify this parameter. - * @param array $params this parameter is ignored in redis implementation. - * @return integer the number of rows updated - */ - public static function updateAllCounters($counters, $condition = null, $params = []) - { - if (empty($counters)) { - return 0; - } - $db = static::getDb(); - $n=0; - foreach(static::fetchPks($condition) as $pk) { - $key = static::tableName() . ':a:' . static::buildKey($pk); - foreach($counters as $attribute => $value) { - $db->executeCommand('HINCRBY', [$key, $attribute, $value]); - } - $n++; - } - return $n; - } - - /** - * Deletes rows in the table 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 conditions that will be put in the WHERE part of the DELETE SQL. - * Please refer to [[ActiveQuery::where()]] on how to specify this parameter. - * @param array $params this parameter is ignored in redis implementation. - * @return integer the number of rows deleted - */ - public static function deleteAll($condition = null, $params = []) - { - $db = static::getDb(); - $attributeKeys = []; - $pks = static::fetchPks($condition); - $db->executeCommand('MULTI'); - foreach($pks as $pk) { - $pk = static::buildKey($pk); - $db->executeCommand('LREM', [static::tableName(), 0, $pk]); - $attributeKeys[] = static::tableName() . ':a:' . $pk; - } - if (empty($attributeKeys)) { - $db->executeCommand('EXEC'); - return 0; - } - $db->executeCommand('DEL', $attributeKeys); - $result = $db->executeCommand('EXEC'); - return end($result); - } - - private static function fetchPks($condition) - { - $query = static::createQuery(); - $query->where($condition); - $records = $query->asArray()->all(); // TODO limit fetched columns to pk - $primaryKey = static::primaryKey(); - - $pks = []; - foreach($records as $record) { - $pk = []; - foreach($primaryKey as $key) { - $pk[$key] = $record[$key]; - } - $pks[] = $pk; - } - return $pks; - } - - /** - * Builds a normalized key from a given primary key value. - * - * @param mixed $key the key to be normalized - * @return string the generated key - */ - public static function buildKey($key) - { - if (is_numeric($key)) { - return $key; - } elseif (is_string($key)) { - return ctype_alnum($key) && StringHelper::strlen($key) <= 32 ? $key : md5($key); - } elseif (is_array($key)) { - if (count($key) == 1) { - return self::buildKey(reset($key)); - } - ksort($key); // ensure order is always the same - $isNumeric = true; - foreach($key as $value) { - if (!is_numeric($value)) { - $isNumeric = false; - } - } - if ($isNumeric) { - return implode('-', $key); - } - } - return md5(json_encode($key)); - } - - /** - * @inheritdoc - */ - public static function getTableSchema() - { - throw new NotSupportedException('getTableSchema() is not supported by redis ActiveRecord'); - } - - /** - * @inheritdoc - */ - public static function findBySql($sql, $params = []) - { - throw new NotSupportedException('findBySql() is not supported by redis ActiveRecord'); - } - - /** - * Returns a value indicating whether the specified operation is transactional in the current [[scenario]]. - * This method will always return false as transactional operations are not supported by redis. - * @param integer $operation the operation to check. Possible values are [[OP_INSERT]], [[OP_UPDATE]] and [[OP_DELETE]]. - * @return boolean whether the specified operation is transactional in the current [[scenario]]. - */ - public function isTransactional($operation) - { - return false; - } -} diff --git a/framework/yii/redis/ActiveRelation.php b/framework/yii/redis/ActiveRelation.php deleted file mode 100644 index b2f5cea..0000000 --- a/framework/yii/redis/ActiveRelation.php +++ /dev/null @@ -1,67 +0,0 @@ - - * @since 2.0 - */ -class ActiveRelation extends ActiveQuery implements ActiveRelationInterface -{ - use ActiveRelationTrait; - - /** - * Executes a script created by [[LuaScriptBuilder]] - * @param Connection $db the database connection used to execute the query. - * If this parameter is not given, the `db` application component will be used. - * @param string $type the type of the script to generate - * @param null $column - * @return array|bool|null|string - */ - protected function executeScript($db, $type, $column=null) - { - if ($this->primaryModel !== null) { - // lazy loading - if ($this->via instanceof self) { - // via pivot table - $viaModels = $this->via->findPivotRows([$this->primaryModel]); - $this->filterByModels($viaModels); - } elseif (is_array($this->via)) { - // via relation - /** @var ActiveRelation $viaQuery */ - 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::executeScript($db, $type, $column); - } -} diff --git a/framework/yii/redis/Connection.php b/framework/yii/redis/Connection.php deleted file mode 100644 index 371b8bc..0000000 --- a/framework/yii/redis/Connection.php +++ /dev/null @@ -1,405 +0,0 @@ - - * @since 2.0 - */ -class Connection extends Component -{ - /** - * @event Event an event that is triggered after a DB connection is established - */ - const EVENT_AFTER_OPEN = 'afterOpen'; - - /** - * @var string the Data Source Name, or DSN, contains the information required to connect to the database. - * DSN format: redis://server:port[/db] - * Where db is a zero based integer which refers to the DB to use. - * If no DB is given, ID 0 is used. - * - * Example: redis://localhost:6379/2 - */ - public $dsn; - /** - * @var string the password for establishing DB connection. Defaults to null meaning no AUTH command is send. - * See http://redis.io/commands/auth - */ - public $password; - /** - * @var float timeout to use for connection to redis. If not set the timeout set in php.ini will be used: ini_get("default_socket_timeout") - */ - public $connectionTimeout = null; - /** - * @var float timeout to use for redis socket when reading and writing data. If not set the php default value will be used. - */ - public $dataTimeout = null; - - /** - * @var array List of available redis commands http://redis.io/commands - */ - public $redisCommands = [ - 'BRPOP', // key [key ...] timeout Remove and get the last element in a list, or block until one is available - 'BRPOPLPUSH', // source destination timeout Pop a value from a list, push it to another list and return it; or block until one is available - 'CLIENT KILL', // ip:port Kill the connection of a client - 'CLIENT LIST', // Get the list of client connections - 'CLIENT GETNAME', // Get the current connection name - 'CLIENT SETNAME', // connection-name Set the current connection name - 'CONFIG GET', // parameter Get the value of a configuration parameter - 'CONFIG SET', // parameter value Set a configuration parameter to the given value - 'CONFIG RESETSTAT', // Reset the stats returned by INFO - 'DBSIZE', // Return the number of keys in the selected database - 'DEBUG OBJECT', // key Get debugging information about a key - 'DEBUG SEGFAULT', // Make the server crash - 'DECR', // key Decrement the integer value of a key by one - 'DECRBY', // key decrement Decrement the integer value of a key by the given number - 'DEL', // key [key ...] Delete a key - 'DISCARD', // Discard all commands issued after MULTI - 'DUMP', // key Return a serialized version of the value stored at the specified key. - 'ECHO', // message Echo the given string - 'EVAL', // script numkeys key [key ...] arg [arg ...] Execute a Lua script server side - 'EVALSHA', // sha1 numkeys key [key ...] arg [arg ...] Execute a Lua script server side - 'EXEC', // Execute all commands issued after MULTI - 'EXISTS', // key Determine if a key exists - 'EXPIRE', // key seconds Set a key's time to live in seconds - 'EXPIREAT', // key timestamp Set the expiration for a key as a UNIX timestamp - 'FLUSHALL', // Remove all keys from all databases - 'FLUSHDB', // Remove all keys from the current database - 'GET', // key Get the value of a key - 'GETBIT', // key offset Returns the bit value at offset in the string value stored at key - 'GETRANGE', // key start end Get a substring of the string stored at a key - 'GETSET', // key value Set the string value of a key and return its old value - 'HDEL', // key field [field ...] Delete one or more hash fields - 'HEXISTS', // key field Determine if a hash field exists - 'HGET', // key field Get the value of a hash field - 'HGETALL', // key Get all the fields and values in a hash - 'HINCRBY', // key field increment Increment the integer value of a hash field by the given number - 'HINCRBYFLOAT', // key field increment Increment the float value of a hash field by the given amount - 'HKEYS', // key Get all the fields in a hash - 'HLEN', // key Get the number of fields in a hash - 'HMGET', // key field [field ...] Get the values of all the given hash fields - 'HMSET', // key field value [field value ...] Set multiple hash fields to multiple values - 'HSET', // key field value Set the string value of a hash field - 'HSETNX', // key field value Set the value of a hash field, only if the field does not exist - 'HVALS', // key Get all the values in a hash - 'INCR', // key Increment the integer value of a key by one - 'INCRBY', // key increment Increment the integer value of a key by the given amount - 'INCRBYFLOAT', // key increment Increment the float value of a key by the given amount - 'INFO', // [section] Get information and statistics about the server - 'KEYS', // pattern Find all keys matching the given pattern - 'LASTSAVE', // Get the UNIX time stamp of the last successful save to disk - 'LINDEX', // key index Get an element from a list by its index - 'LINSERT', // key BEFORE|AFTER pivot value Insert an element before or after another element in a list - 'LLEN', // key Get the length of a list - 'LPOP', // key Remove and get the first element in a list - 'LPUSH', // key value [value ...] Prepend one or multiple values to a list - 'LPUSHX', // key value Prepend a value to a list, only if the list exists - 'LRANGE', // key start stop Get a range of elements from a list - 'LREM', // key count value Remove elements from a list - 'LSET', // key index value Set the value of an element in a list by its index - 'LTRIM', // key start stop Trim a list to the specified range - 'MGET', // key [key ...] Get the values of all the given keys - 'MIGRATE', // host port key destination-db timeout Atomically transfer a key from a Redis instance to another one. - 'MONITOR', // Listen for all requests received by the server in real time - 'MOVE', // key db Move a key to another database - 'MSET', // key value [key value ...] Set multiple keys to multiple values - 'MSETNX', // key value [key value ...] Set multiple keys to multiple values, only if none of the keys exist - 'MULTI', // Mark the start of a transaction block - 'OBJECT', // subcommand [arguments [arguments ...]] Inspect the internals of Redis objects - 'PERSIST', // key Remove the expiration from a key - 'PEXPIRE', // key milliseconds Set a key's time to live in milliseconds - 'PEXPIREAT', // key milliseconds-timestamp Set the expiration for a key as a UNIX timestamp specified in milliseconds - 'PING', // Ping the server - 'PSETEX', // key milliseconds value Set the value and expiration in milliseconds of a key - 'PSUBSCRIBE', // pattern [pattern ...] Listen for messages published to channels matching the given patterns - 'PTTL', // key Get the time to live for a key in milliseconds - 'PUBLISH', // channel message Post a message to a channel - 'PUNSUBSCRIBE', // [pattern [pattern ...]] Stop listening for messages posted to channels matching the given patterns - 'QUIT', // Close the connection - 'RANDOMKEY', // Return a random key from the keyspace - 'RENAME', // key newkey Rename a key - 'RENAMENX', // key newkey Rename a key, only if the new key does not exist - 'RESTORE', // key ttl serialized-value Create a key using the provided serialized value, previously obtained using DUMP. - 'RPOP', // key Remove and get the last element in a list - 'RPOPLPUSH', // source destination Remove the last element in a list, append it to another list and return it - 'RPUSH', // key value [value ...] Append one or multiple values to a list - 'RPUSHX', // key value Append a value to a list, only if the list exists - 'SADD', // key member [member ...] Add one or more members to a set - 'SAVE', // Synchronously save the dataset to disk - 'SCARD', // key Get the number of members in a set - 'SCRIPT EXISTS', // script [script ...] Check existence of scripts in the script cache. - 'SCRIPT FLUSH', // Remove all the scripts from the script cache. - 'SCRIPT KILL', // Kill the script currently in execution. - 'SCRIPT LOAD', // script Load the specified Lua script into the script cache. - 'SDIFF', // key [key ...] Subtract multiple sets - 'SDIFFSTORE', // destination key [key ...] Subtract multiple sets and store the resulting set in a key - 'SELECT', // index Change the selected database for the current connection - 'SET', // key value Set the string value of a key - 'SETBIT', // key offset value Sets or clears the bit at offset in the string value stored at key - 'SETEX', // key seconds value Set the value and expiration of a key - 'SETNX', // key value Set the value of a key, only if the key does not exist - 'SETRANGE', // key offset value Overwrite part of a string at key starting at the specified offset - 'SHUTDOWN', // [NOSAVE] [SAVE] Synchronously save the dataset to disk and then shut down the server - 'SINTER', // key [key ...] Intersect multiple sets - 'SINTERSTORE', // destination key [key ...] Intersect multiple sets and store the resulting set in a key - 'SISMEMBER', // key member Determine if a given value is a member of a set - 'SLAVEOF', // host port Make the server a slave of another instance, or promote it as master - 'SLOWLOG', // subcommand [argument] Manages the Redis slow queries log - 'SMEMBERS', // key Get all the members in a set - 'SMOVE', // source destination member Move a member from one set to another - 'SORT', // key [BY pattern] [LIMIT offset count] [GET pattern [GET pattern ...]] [ASC|DESC] [ALPHA] [STORE destination] Sort the elements in a list, set or sorted set - 'SPOP', // key Remove and return a random member from a set - 'SRANDMEMBER', // key [count] Get one or multiple random members from a set - 'SREM', // key member [member ...] Remove one or more members from a set - 'STRLEN', // key Get the length of the value stored in a key - 'SUBSCRIBE', // channel [channel ...] Listen for messages published to the given channels - 'SUNION', // key [key ...] Add multiple sets - 'SUNIONSTORE', // destination key [key ...] Add multiple sets and store the resulting set in a key - 'SYNC', // Internal command used for replication - 'TIME', // Return the current server time - 'TTL', // key Get the time to live for a key - 'TYPE', // key Determine the type stored at key - 'UNSUBSCRIBE', // [channel [channel ...]] Stop listening for messages posted to the given channels - 'UNWATCH', // Forget about all watched keys - 'WATCH', // key [key ...] Watch the given keys to determine execution of the MULTI/EXEC block - 'ZADD', // key score member [score member ...] Add one or more members to a sorted set, or update its score if it already exists - 'ZCARD', // key Get the number of members in a sorted set - 'ZCOUNT', // key min max Count the members in a sorted set with scores within the given values - 'ZINCRBY', // key increment member Increment the score of a member in a sorted set - 'ZINTERSTORE', // destination numkeys key [key ...] [WEIGHTS weight [weight ...]] [AGGREGATE SUM|MIN|MAX] Intersect multiple sorted sets and store the resulting sorted set in a new key - 'ZRANGE', // key start stop [WITHSCORES] Return a range of members in a sorted set, by index - 'ZRANGEBYSCORE', // key min max [WITHSCORES] [LIMIT offset count] Return a range of members in a sorted set, by score - 'ZRANK', // key member Determine the index of a member in a sorted set - 'ZREM', // key member [member ...] Remove one or more members from a sorted set - 'ZREMRANGEBYRANK', // key start stop Remove all members in a sorted set within the given indexes - 'ZREMRANGEBYSCORE', // key min max Remove all members in a sorted set within the given scores - 'ZREVRANGE', // key start stop [WITHSCORES] Return a range of members in a sorted set, by index, with scores ordered from high to low - 'ZREVRANGEBYSCORE', // key max min [WITHSCORES] [LIMIT offset count] Return a range of members in a sorted set, by score, with scores ordered from high to low - 'ZREVRANK', // key member Determine the index of a member in a sorted set, with scores ordered from high to low - 'ZSCORE', // key member Get the score associated with the given member in a sorted set - 'ZUNIONSTORE', // destination numkeys key [key ...] [WEIGHTS weight [weight ...]] [AGGREGATE SUM|MIN|MAX] Add multiple sorted sets and store the resulting sorted set in a new key - ]; - /** - * @var resource redis socket connection - */ - private $_socket; - - /** - * Closes the connection when this component is being serialized. - * @return array - */ - public function __sleep() - { - $this->close(); - return array_keys(get_object_vars($this)); - } - - /** - * Returns a value indicating whether the DB connection is established. - * @return boolean whether the DB connection is established - */ - public function getIsActive() - { - return $this->_socket !== null; - } - - /** - * Establishes a DB connection. - * It does nothing if a DB connection has already been established. - * @throws Exception if connection fails - */ - public function open() - { - if ($this->_socket === null) { - if (empty($this->dsn)) { - throw new InvalidConfigException('Connection.dsn cannot be empty.'); - } - $dsn = explode('/', $this->dsn); - $host = $dsn[2]; - if (strpos($host, ':')===false) { - $host .= ':6379'; - } - $db = isset($dsn[3]) ? $dsn[3] : 0; - - \Yii::trace('Opening DB connection: ' . $this->dsn, __CLASS__); - $this->_socket = @stream_socket_client( - $host, - $errorNumber, - $errorDescription, - $this->connectionTimeout ? $this->connectionTimeout : ini_get("default_socket_timeout") - ); - if ($this->_socket) { - if ($this->dataTimeout !== null) { - stream_set_timeout($this->_socket, $timeout=(int)$this->dataTimeout, (int) (($this->dataTimeout - $timeout) * 1000000)); - } - if ($this->password !== null) { - $this->executeCommand('AUTH', [$this->password]); - } - $this->executeCommand('SELECT', [$db]); - $this->initConnection(); - } else { - \Yii::error("Failed to open DB connection ({$this->dsn}): " . $errorNumber . ' - ' . $errorDescription, __CLASS__); - $message = YII_DEBUG ? 'Failed to open DB connection: ' . $errorNumber . ' - ' . $errorDescription : 'Failed to open DB connection.'; - throw new Exception($message, $errorDescription, (int)$errorNumber); - } - } - } - - /** - * Closes the currently active DB connection. - * It does nothing if the connection is already closed. - */ - public function close() - { - if ($this->_socket !== null) { - \Yii::trace('Closing DB connection: ' . $this->dsn, __CLASS__); - $this->executeCommand('QUIT'); - stream_socket_shutdown($this->_socket, STREAM_SHUT_RDWR); - $this->_socket = null; - } - } - - /** - * Initializes the DB connection. - * This method is invoked right after the DB connection is established. - * The default implementation triggers an [[EVENT_AFTER_OPEN]] event. - */ - protected function initConnection() - { - $this->trigger(self::EVENT_AFTER_OPEN); - } - - /** - * Returns the name of the DB driver for the current [[dsn]]. - * @return string name of the DB driver - */ - public function getDriverName() - { - if (($pos = strpos($this->dsn, ':')) !== false) { - return strtolower(substr($this->dsn, 0, $pos)); - } else { - return 'redis'; - } - } - - /** - * @return LuaScriptBuilder - */ - public function getLuaScriptBuilder() - { - return new LuaScriptBuilder(); - } - - /** - * - * @param string $name - * @param array $params - * @return mixed - */ - public function __call($name, $params) - { - $redisCommand = strtoupper(Inflector::camel2words($name, false)); - if (in_array($redisCommand, $this->redisCommands)) { - return $this->executeCommand($name, $params); - } else { - return parent::__call($name, $params); - } - } - - /** - * Executes a redis command. - * For a list of available commands and their parameters see http://redis.io/commands. - * - * @param string $name the name of the command - * @param array $params list of parameters for the command - * @return array|bool|null|string Dependend on the executed command this method - * will return different data types: - * - * - `true` for commands that return "status reply". - * - `string` for commands that return "integer reply" - * as the value is in the range of a signed 64 bit integer. - * - `string` or `null` for commands that return "bulk reply". - * - `array` for commands that return "Multi-bulk replies". - * - * See [redis protocol description](http://redis.io/topics/protocol) - * for details on the mentioned reply types. - * @trows Exception for commands that return [error reply](http://redis.io/topics/protocol#error-reply). - */ - public function executeCommand($name, $params=[]) - { - $this->open(); - - array_unshift($params, $name); - $command = '*' . count($params) . "\r\n"; - foreach($params as $arg) { - $command .= '$' . mb_strlen($arg, '8bit') . "\r\n" . $arg . "\r\n"; - } - - \Yii::trace("Executing Redis Command: {$name}", __CLASS__); - fwrite($this->_socket, $command); - - return $this->parseResponse(implode(' ', $params)); - } - - private function parseResponse($command) - { - if (($line = fgets($this->_socket)) === false) { - throw new Exception("Failed to read from socket.\nRedis command was: " . $command); - } - $type = $line[0]; - $line = mb_substr($line, 1, -2, '8bit'); - switch($type) - { - case '+': // Status reply - return true; - case '-': // Error reply - throw new Exception("Redis error: " . $line . "\nRedis command was: " . $command); - case ':': // Integer reply - // no cast to int as it is in the range of a signed 64 bit integer - return $line; - case '$': // Bulk replies - if ($line == '-1') { - return null; - } - $length = $line + 2; - $data = ''; - while ($length > 0) { - if (($block = fread($this->_socket, $line + 2)) === false) { - throw new Exception("Failed to read from socket.\nRedis command was: " . $command); - } - $data .= $block; - $length -= mb_strlen($block, '8bit'); - } - return mb_substr($data, 0, -2, '8bit'); - case '*': // Multi-bulk replies - $count = (int) $line; - $data = []; - for ($i = 0; $i < $count; $i++) { - $data[] = $this->parseResponse($command); - } - return $data; - default: - throw new Exception('Received illegal data from redis: ' . $line . "\nRedis command was: " . $command); - } - } -} diff --git a/framework/yii/redis/LuaScriptBuilder.php b/framework/yii/redis/LuaScriptBuilder.php deleted file mode 100644 index 81dff3f..0000000 --- a/framework/yii/redis/LuaScriptBuilder.php +++ /dev/null @@ -1,365 +0,0 @@ - - * @since 2.0 - */ -class LuaScriptBuilder extends \yii\base\Object -{ - /** - * Builds a Lua script for finding a list of records - * @param ActiveQuery $query the query used to build the script - * @return string - */ - public function buildAll($query) - { - // TODO add support for orderBy - /** @var ActiveRecord $modelClass */ - $modelClass = $query->modelClass; - $key = $this->quoteValue($modelClass::tableName() . ':a:'); - return $this->build($query, "n=n+1 pks[n]=redis.call('HGETALL',$key .. pk)", 'pks'); - } - - /** - * Builds a Lua script for finding one record - * @param ActiveQuery $query the query used to build the script - * @return string - */ - public function buildOne($query) - { - // TODO add support for orderBy - /** @var ActiveRecord $modelClass */ - $modelClass = $query->modelClass; - $key = $this->quoteValue($modelClass::tableName() . ':a:'); - return $this->build($query, "do return redis.call('HGETALL',$key .. pk) end", 'pks'); - } - - /** - * Builds a Lua script for finding a column - * @param ActiveQuery $query the query used to build the script - * @param string $column name of the column - * @return string - */ - public function buildColumn($query, $column) - { - // TODO add support for orderBy and indexBy - /** @var ActiveRecord $modelClass */ - $modelClass = $query->modelClass; - $key = $this->quoteValue($modelClass::tableName() . ':a:'); - return $this->build($query, "n=n+1 pks[n]=redis.call('HGET',$key .. pk," . $this->quoteValue($column) . ")", 'pks'); - } - - /** - * Builds a Lua script for getting count of records - * @param ActiveQuery $query the query used to build the script - * @return string - */ - public function buildCount($query) - { - return $this->build($query, 'n=n+1', 'n'); - } - - /** - * Builds a Lua script for finding the sum of a column - * @param ActiveQuery $query the query used to build the script - * @param string $column name of the column - * @return string - */ - public function buildSum($query, $column) - { - /** @var ActiveRecord $modelClass */ - $modelClass = $query->modelClass; - $key = $this->quoteValue($modelClass::tableName() . ':a:'); - return $this->build($query, "n=n+redis.call('HGET',$key .. pk," . $this->quoteValue($column) . ")", 'n'); - } - - /** - * Builds a Lua script for finding the average of a column - * @param ActiveQuery $query the query used to build the script - * @param string $column name of the column - * @return string - */ - public function buildAverage($query, $column) - { - /** @var ActiveRecord $modelClass */ - $modelClass = $query->modelClass; - $key = $this->quoteValue($modelClass::tableName() . ':a:'); - return $this->build($query, "n=n+1 if v==nil then v=0 end v=v+redis.call('HGET',$key .. pk," . $this->quoteValue($column) . ")", 'v/n'); - } - - /** - * Builds a Lua script for finding the min value of a column - * @param ActiveQuery $query the query used to build the script - * @param string $column name of the column - * @return string - */ - public function buildMin($query, $column) - { - /** @var ActiveRecord $modelClass */ - $modelClass = $query->modelClass; - $key = $this->quoteValue($modelClass::tableName() . ':a:'); - return $this->build($query, "n=redis.call('HGET',$key .. pk," . $this->quoteValue($column) . ") if v==nil or nmodelClass; - $key = $this->quoteValue($modelClass::tableName() . ':a:'); - return $this->build($query, "n=redis.call('HGET',$key .. pk," . $this->quoteValue($column) . ") if v==nil or n>v then v=n end", 'v'); - } - - /** - * @param ActiveQuery $query the query used to build the script - * @param string $buildResult the lua script for building the result - * @param string $return the lua variable that should be returned - * @return string - */ - private function build($query, $buildResult, $return) - { - if (!empty($query->orderBy)) { - throw new NotSupportedException('orderBy is currently not supported by redis ActiveRecord.'); - } - - $columns = []; - if ($query->where !== null) { - $condition = $this->buildCondition($query->where, $columns); - } else { - $condition = 'true'; - } - - $start = $query->offset === null ? 0 : $query->offset; - $limitCondition = 'i>' . $start . ($query->limit === null ? '' : ' and i<=' . ($start + $query->limit)); - - /** @var ActiveRecord $modelClass */ - $modelClass = $query->modelClass; - $key = $this->quoteValue($modelClass::tableName()); - $loadColumnValues = ''; - foreach($columns as $column => $alias) { - $loadColumnValues .= "local $alias=redis.call('HGET',$key .. ':a:' .. pk, '$column')\n"; - } - - return << 'buildAndCondition', - 'or' => 'buildAndCondition', - 'between' => 'buildBetweenCondition', - 'not between' => 'buildBetweenCondition', - 'in' => 'buildInCondition', - 'not in' => 'buildInCondition', - 'like' => 'buildLikeCondition', - 'not like' => 'buildLikeCondition', - 'or like' => 'buildLikeCondition', - 'or not like' => 'buildLikeCondition', - ]; - - if (!is_array($condition)) { - throw new NotSupportedException('Where condition must be an array in redis ActiveRecord.'); - } - if (isset($condition[0])) { // operator format: operator, operand 1, operand 2, ... - $operator = strtolower($condition[0]); - if (isset($builders[$operator])) { - $method = $builders[$operator]; - array_shift($condition); - return $this->$method($operator, $condition, $columns); - } else { - throw new Exception('Found unknown operator in query: ' . $operator); - } - } else { // hash format: 'column1' => 'value1', 'column2' => 'value2', ... - return $this->buildHashCondition($condition, $columns); - } - } - - private function buildHashCondition($condition, &$columns) - { - $parts = []; - foreach ($condition as $column => $value) { - if (is_array($value)) { // IN condition - $parts[] = $this->buildInCondition('in', [$column, $value], $columns); - } else { - $column = $this->addColumn($column, $columns); - if ($value === null) { - $parts[] = "$column==nil"; - } elseif ($value instanceof Expression) { - $parts[] = "$column==" . $value->expression; - } else { - $value = $this->quoteValue($value); - $parts[] = "$column==$value"; - } - } - } - return count($parts) === 1 ? $parts[0] : '(' . implode(') and (', $parts) . ')'; - } - - private function buildAndCondition($operator, $operands, &$columns) - { - $parts = []; - foreach ($operands as $operand) { - if (is_array($operand)) { - $operand = $this->buildCondition($operand, $columns); - } - if ($operand !== '') { - $parts[] = $operand; - } - } - if (!empty($parts)) { - return '(' . implode(") $operator (", $parts) . ')'; - } else { - return ''; - } - } - - private function buildBetweenCondition($operator, $operands, &$columns) - { - if (!isset($operands[0], $operands[1], $operands[2])) { - throw new Exception("Operator '$operator' requires three operands."); - } - - list($column, $value1, $value2) = $operands; - - $value1 = $this->quoteValue($value1); - $value2 = $this->quoteValue($value2); - $column = $this->addColumn($column, $columns); - return "$column >= $value1 and $column <= $value2"; - } - - private function buildInCondition($operator, $operands, &$columns) - { - if (!isset($operands[0], $operands[1])) { - throw new Exception("Operator '$operator' requires two operands."); - } - - list($column, $values) = $operands; - - $values = (array)$values; - - if (empty($values) || $column === []) { - return $operator === 'in' ? 'false' : 'true'; - } - - if (count($column) > 1) { - return $this->buildCompositeInCondition($operator, $column, $values, $columns); - } elseif (is_array($column)) { - $column = reset($column); - } - $columnAlias = $this->addColumn($column, $columns); - $parts = []; - foreach ($values as $i => $value) { - if (is_array($value)) { - $value = isset($value[$column]) ? $value[$column] : null; - } - if ($value === null) { - $parts[] = "$columnAlias==nil"; - } elseif ($value instanceof Expression) { - $parts[] = "$columnAlias==" . $value->expression; - } else { - $value = $this->quoteValue($value); - $parts[] = "$columnAlias==$value"; - } - } - $operator = $operator === 'in' ? '' : 'not '; - return "$operator(" . implode(' or ', $parts) . ')'; - } - - protected function buildCompositeInCondition($operator, $inColumns, $values, &$columns) - { - $vss = []; - foreach ($values as $value) { - $vs = []; - foreach ($inColumns as $column) { - $column = $this->addColumn($column, $columns); - if (isset($value[$column])) { - $vs[] = "$column==" . $this->quoteValue($value[$column]); - } else { - $vs[] = "$column==nil"; - } - } - $vss[] = '(' . implode(' and ', $vs) . ')'; - } - $operator = $operator === 'in' ? '' : 'not '; - return "$operator(" . implode(' or ', $vss) . ')'; - } - - private function buildLikeCondition($operator, $operands, &$columns) - { - throw new NotSupportedException('LIKE conditions are not suppoerted by redis ActiveRecord.'); - } -} diff --git a/tests/unit/data/config.php b/tests/unit/data/config.php index 13a1026..28e5abe 100644 --- a/tests/unit/data/config.php +++ b/tests/unit/data/config.php @@ -30,7 +30,9 @@ return [ 'fixture' => __DIR__ . '/postgres.sql', ], 'redis' => [ - 'dsn' => 'redis://localhost:6379/0', + 'hostname' => 'localhost', + 'port' => 6379, + 'database' => 0, 'password' => null, ], ], diff --git a/tests/unit/extensions/redis/ActiveRecordTest.php b/tests/unit/extensions/redis/ActiveRecordTest.php new file mode 100644 index 0000000..74dd49e --- /dev/null +++ b/tests/unit/extensions/redis/ActiveRecordTest.php @@ -0,0 +1,465 @@ +getConnection(); + + $customer = new Customer(); + $customer->setAttributes(['email' => 'user1@example.com', 'name' => 'user1', 'address' => 'address1', 'status' => 1], false); + $customer->save(false); + $customer = new Customer(); + $customer->setAttributes(['email' => 'user2@example.com', 'name' => 'user2', 'address' => 'address2', 'status' => 1], false); + $customer->save(false); + $customer = new Customer(); + $customer->setAttributes(['email' => 'user3@example.com', 'name' => 'user3', 'address' => 'address3', 'status' => 2], false); + $customer->save(false); + +// INSERT INTO tbl_category (name) VALUES ('Books'); +// INSERT INTO tbl_category (name) VALUES ('Movies'); + + $item = new Item(); + $item->setAttributes(['name' => 'Agile Web Application Development with Yii1.1 and PHP5', 'category_id' => 1], false); + $item->save(false); + $item = new Item(); + $item->setAttributes(['name' => 'Yii 1.1 Application Development Cookbook', 'category_id' => 1], false); + $item->save(false); + $item = new Item(); + $item->setAttributes(['name' => 'Ice Age', 'category_id' => 2], false); + $item->save(false); + $item = new Item(); + $item->setAttributes(['name' => 'Toy Story', 'category_id' => 2], false); + $item->save(false); + $item = new Item(); + $item->setAttributes(['name' => 'Cars', 'category_id' => 2], false); + $item->save(false); + + $order = new Order(); + $order->setAttributes(['customer_id' => 1, 'create_time' => 1325282384, 'total' => 110.0], false); + $order->save(false); + $order = new Order(); + $order->setAttributes(['customer_id' => 2, 'create_time' => 1325334482, 'total' => 33.0], false); + $order->save(false); + $order = new Order(); + $order->setAttributes(['customer_id' => 2, 'create_time' => 1325502201, 'total' => 40.0], false); + $order->save(false); + + $orderItem = new OrderItem(); + $orderItem->setAttributes(['order_id' => 1, 'item_id' => 1, 'quantity' => 1, 'subtotal' => 30.0], false); + $orderItem->save(false); + $orderItem = new OrderItem(); + $orderItem->setAttributes(['order_id' => 1, 'item_id' => 2, 'quantity' => 2, 'subtotal' => 40.0], false); + $orderItem->save(false); + $orderItem = new OrderItem(); + $orderItem->setAttributes(['order_id' => 2, 'item_id' => 4, 'quantity' => 1, 'subtotal' => 10.0], false); + $orderItem->save(false); + $orderItem = new OrderItem(); + $orderItem->setAttributes(['order_id' => 2, 'item_id' => 5, 'quantity' => 1, 'subtotal' => 15.0], false); + $orderItem->save(false); + $orderItem = new OrderItem(); + $orderItem->setAttributes(['order_id' => 2, 'item_id' => 3, 'quantity' => 1, 'subtotal' => 8.0], false); + $orderItem->save(false); + $orderItem = new OrderItem(); + $orderItem->setAttributes(['order_id' => 3, 'item_id' => 2, 'quantity' => 1, 'subtotal' => 40.0], false); + $orderItem->save(false); + } + + public function testFind() + { + // find one + $result = Customer::find(); + $this->assertTrue($result instanceof ActiveQuery); + $customer = $result->one(); + $this->assertTrue($customer instanceof Customer); + + // find all + $customers = Customer::find()->all(); + $this->assertEquals(3, count($customers)); + $this->assertTrue($customers[0] instanceof Customer); + $this->assertTrue($customers[1] instanceof Customer); + $this->assertTrue($customers[2] instanceof Customer); + + // find by a single primary key + $customer = Customer::find(2); + $this->assertTrue($customer instanceof Customer); + $this->assertEquals('user2', $customer->name); + $customer = Customer::find(5); + $this->assertNull($customer); + $customer = Customer::find(['id' => [5, 6, 1]]); + $this->assertEquals(1, count($customer)); + $customer = Customer::find()->where(['id' => [5, 6, 1]])->one(); + $this->assertNotNull($customer); + + // query scalar + $customerName = Customer::find()->where(['id' => 2])->scalar('name'); + $this->assertEquals('user2', $customerName); + + // find by column values + $customer = Customer::find(['id' => 2, 'name' => 'user2']); + $this->assertTrue($customer instanceof Customer); + $this->assertEquals('user2', $customer->name); + $customer = Customer::find(['id' => 2, 'name' => 'user1']); + $this->assertNull($customer); + $customer = Customer::find(['id' => 5]); + $this->assertNull($customer); + + // find by attributes + $customer = Customer::find()->where(['name' => 'user2'])->one(); + $this->assertTrue($customer instanceof Customer); + $this->assertEquals(2, $customer->id); + + // find count, sum, average, min, max, scalar + $this->assertEquals(3, Customer::find()->count()); + $this->assertEquals(6, Customer::find()->sum('id')); + $this->assertEquals(2, Customer::find()->average('id')); + $this->assertEquals(1, Customer::find()->min('id')); + $this->assertEquals(3, Customer::find()->max('id')); + + // scope + $this->assertEquals(2, Customer::find()->active()->count()); + + // asArray + $customer = Customer::find()->where(['id' => 2])->asArray()->one(); + $this->assertEquals(array( + 'id' => '2', + 'email' => 'user2@example.com', + 'name' => 'user2', + 'address' => 'address2', + 'status' => '1', + ), $customer); + + // indexBy + $customers = Customer::find()->indexBy('name')->all(); + $this->assertEquals(3, count($customers)); + $this->assertTrue($customers['user1'] instanceof Customer); + $this->assertTrue($customers['user2'] instanceof Customer); + $this->assertTrue($customers['user3'] instanceof Customer); + + // indexBy callable + $customers = Customer::find()->indexBy(function ($customer) { + return $customer->id . '-' . $customer->name; +// })->orderBy('id')->all(); + })->all(); + $this->assertEquals(3, count($customers)); + $this->assertTrue($customers['1-user1'] instanceof Customer); + $this->assertTrue($customers['2-user2'] instanceof Customer); + $this->assertTrue($customers['3-user3'] instanceof Customer); + } + + public function testFindCount() + { + $this->assertEquals(3, Customer::find()->count()); + $this->assertEquals(1, Customer::find()->limit(1)->count()); + $this->assertEquals(2, Customer::find()->limit(2)->count()); + $this->assertEquals(1, Customer::find()->offset(2)->limit(2)->count()); + } + + public function testFindLimit() + { + // all() + $customers = Customer::find()->all(); + $this->assertEquals(3, count($customers)); + + $customers = Customer::find()->limit(1)->all(); + $this->assertEquals(1, count($customers)); + $this->assertEquals('user1', $customers[0]->name); + + $customers = Customer::find()->limit(1)->offset(1)->all(); + $this->assertEquals(1, count($customers)); + $this->assertEquals('user2', $customers[0]->name); + + $customers = Customer::find()->limit(1)->offset(2)->all(); + $this->assertEquals(1, count($customers)); + $this->assertEquals('user3', $customers[0]->name); + + $customers = Customer::find()->limit(2)->offset(1)->all(); + $this->assertEquals(2, count($customers)); + $this->assertEquals('user2', $customers[0]->name); + $this->assertEquals('user3', $customers[1]->name); + + $customers = Customer::find()->limit(2)->offset(3)->all(); + $this->assertEquals(0, count($customers)); + + // one() + $customer = Customer::find()->one(); + $this->assertEquals('user1', $customer->name); + + $customer = Customer::find()->offset(0)->one(); + $this->assertEquals('user1', $customer->name); + + $customer = Customer::find()->offset(1)->one(); + $this->assertEquals('user2', $customer->name); + + $customer = Customer::find()->offset(2)->one(); + $this->assertEquals('user3', $customer->name); + + $customer = Customer::find()->offset(3)->one(); + $this->assertNull($customer); + + } + + public function testFindComplexCondition() + { + $this->assertEquals(2, Customer::find()->where(['OR', ['id' => 1], ['id' => 2]])->count()); + $this->assertEquals(2, count(Customer::find()->where(['OR', ['id' => 1], ['id' => 2]])->all())); + + $this->assertEquals(2, Customer::find()->where(['id' => [1,2]])->count()); + $this->assertEquals(2, count(Customer::find()->where(['id' => [1,2]])->all())); + + $this->assertEquals(1, Customer::find()->where(['AND', ['id' => [2,3]], ['BETWEEN', 'status', 2, 4]])->count()); + $this->assertEquals(1, count(Customer::find()->where(['AND', ['id' => [2,3]], ['BETWEEN', 'status', 2, 4]])->all())); + } + + public function testSum() + { + $this->assertEquals(6, OrderItem::find()->count()); + $this->assertEquals(7, OrderItem::find()->sum('quantity')); + } + + public function testFindColumn() + { + $this->assertEquals(['user1', 'user2', 'user3'], Customer::find()->column('name')); +// TODO $this->assertEquals(['user3', 'user2', 'user1'], Customer::find()->orderBy(['name' => SORT_DESC])->column('name')); + } + + public function testExists() + { + $this->assertTrue(Customer::find()->where(['id' => 2])->exists()); + $this->assertFalse(Customer::find()->where(['id' => 5])->exists()); + } + + public function testFindLazy() + { + /** @var $customer Customer */ + $customer = Customer::find(2); + $orders = $customer->orders; + $this->assertEquals(2, count($orders)); + + $orders = $customer->getOrders()->where(['id' => 3])->all(); + $this->assertEquals(1, count($orders)); + $this->assertEquals(3, $orders[0]->id); + } + + public function testFindEager() + { + $customers = Customer::find()->with('orders')->all(); + $this->assertEquals(3, count($customers)); + $this->assertEquals(1, count($customers[0]->orders)); + $this->assertEquals(2, count($customers[1]->orders)); + } + + public function testFindLazyVia() + { + /** @var $order Order */ + $order = Order::find(1); + $this->assertEquals(1, $order->id); + $this->assertEquals(2, count($order->items)); + $this->assertEquals(1, $order->items[0]->id); + $this->assertEquals(2, $order->items[1]->id); + + $order = Order::find(1); + $order->id = 100; + $this->assertEquals([], $order->items); + } + + public function testFindEagerViaRelation() + { + $orders = Order::find()->with('items')->all(); + $this->assertEquals(3, count($orders)); + $order = $orders[0]; + $this->assertEquals(1, $order->id); + $this->assertEquals(2, count($order->items)); + $this->assertEquals(1, $order->items[0]->id); + $this->assertEquals(2, $order->items[1]->id); + } + + public function testFindNestedRelation() + { + $customers = Customer::find()->with('orders', 'orders.items')->all(); + $this->assertEquals(3, count($customers)); + $this->assertEquals(1, count($customers[0]->orders)); + $this->assertEquals(2, count($customers[1]->orders)); + $this->assertEquals(0, count($customers[2]->orders)); + $this->assertEquals(2, count($customers[0]->orders[0]->items)); + $this->assertEquals(3, count($customers[1]->orders[0]->items)); + $this->assertEquals(1, count($customers[1]->orders[1]->items)); + } + + public function testLink() + { + $customer = Customer::find(2); + $this->assertEquals(2, count($customer->orders)); + + // has many + $order = new Order; + $order->total = 100; + $this->assertTrue($order->isNewRecord); + $customer->link('orders', $order); + $this->assertEquals(3, count($customer->orders)); + $this->assertFalse($order->isNewRecord); + $this->assertEquals(3, count($customer->getOrders()->all())); + $this->assertEquals(2, $order->customer_id); + + // belongs to + $order = new Order; + $order->total = 100; + $this->assertTrue($order->isNewRecord); + $customer = Customer::find(1); + $this->assertNull($order->customer); + $order->link('customer', $customer); + $this->assertFalse($order->isNewRecord); + $this->assertEquals(1, $order->customer_id); + $this->assertEquals(1, $order->customer->id); + + // via model + $order = Order::find(1); + $this->assertEquals(2, count($order->items)); + $this->assertEquals(2, count($order->orderItems)); + $orderItem = OrderItem::find(['order_id' => 1, 'item_id' => 3]); + $this->assertNull($orderItem); + $item = Item::find(3); + $order->link('items', $item, ['quantity' => 10, 'subtotal' => 100]); + $this->assertEquals(3, count($order->items)); + $this->assertEquals(3, count($order->orderItems)); + $orderItem = OrderItem::find(['order_id' => 1, 'item_id' => 3]); + $this->assertTrue($orderItem instanceof OrderItem); + $this->assertEquals(10, $orderItem->quantity); + $this->assertEquals(100, $orderItem->subtotal); + } + + public function testUnlink() + { + // has many + $customer = Customer::find(2); + $this->assertEquals(2, count($customer->orders)); + $customer->unlink('orders', $customer->orders[1], true); + $this->assertEquals(1, count($customer->orders)); + $this->assertNull(Order::find(3)); + + // via model + $order = Order::find(2); + $this->assertEquals(3, count($order->items)); + $this->assertEquals(3, count($order->orderItems)); + $order->unlink('items', $order->items[2], true); + $this->assertEquals(2, count($order->items)); + $this->assertEquals(2, count($order->orderItems)); + } + + public function testInsert() + { + $customer = new Customer; + $customer->email = 'user4@example.com'; + $customer->name = 'user4'; + $customer->address = 'address4'; + + $this->assertNull($customer->id); + $this->assertTrue($customer->isNewRecord); + + $customer->save(); + + $this->assertEquals(4, $customer->id); + $this->assertFalse($customer->isNewRecord); + } + + // TODO test serial column incr + + public function testUpdate() + { + // save + $customer = Customer::find(2); + $this->assertTrue($customer instanceof Customer); + $this->assertEquals('user2', $customer->name); + $this->assertFalse($customer->isNewRecord); + $customer->name = 'user2x'; + $customer->save(); + $this->assertEquals('user2x', $customer->name); + $this->assertFalse($customer->isNewRecord); + $customer2 = Customer::find(2); + $this->assertEquals('user2x', $customer2->name); + + // updateAll + $customer = Customer::find(3); + $this->assertEquals('user3', $customer->name); + $ret = Customer::updateAll(array( + 'name' => 'temp', + ), ['id' => 3]); + $this->assertEquals(1, $ret); + $customer = Customer::find(3); + $this->assertEquals('temp', $customer->name); + } + + public function testUpdateCounters() + { + // updateCounters + $pk = ['order_id' => 2, 'item_id' => 4]; + $orderItem = OrderItem::find($pk); + $this->assertEquals(1, $orderItem->quantity); + $ret = $orderItem->updateCounters(['quantity' => -1]); + $this->assertTrue($ret); + $this->assertEquals(0, $orderItem->quantity); + $orderItem = OrderItem::find($pk); + $this->assertEquals(0, $orderItem->quantity); + + // updateAllCounters + $pk = ['order_id' => 1, 'item_id' => 2]; + $orderItem = OrderItem::find($pk); + $this->assertEquals(2, $orderItem->quantity); + $ret = OrderItem::updateAllCounters(array( + 'quantity' => 3, + 'subtotal' => -10, + ), $pk); + $this->assertEquals(1, $ret); + $orderItem = OrderItem::find($pk); + $this->assertEquals(5, $orderItem->quantity); + $this->assertEquals(30, $orderItem->subtotal); + } + + public function testUpdatePk() + { + // updateCounters + $pk = ['order_id' => 2, 'item_id' => 4]; + $orderItem = OrderItem::find($pk); + $this->assertEquals(2, $orderItem->order_id); + $this->assertEquals(4, $orderItem->item_id); + + $orderItem->order_id = 2; + $orderItem->item_id = 10; + $orderItem->save(); + + $this->assertNull(OrderItem::find($pk)); + $this->assertNotNull(OrderItem::find(['order_id' => 2, 'item_id' => 10])); + } + + public function testDelete() + { + // delete + $customer = Customer::find(2); + $this->assertTrue($customer instanceof Customer); + $this->assertEquals('user2', $customer->name); + $customer->delete(); + $customer = Customer::find(2); + $this->assertNull($customer); + + // deleteAll + $customers = Customer::find()->all(); + $this->assertEquals(2, count($customers)); + $ret = Customer::deleteAll(); + $this->assertEquals(2, $ret); + $customers = Customer::find()->all(); + $this->assertEquals(0, count($customers)); + } +} \ No newline at end of file diff --git a/tests/unit/extensions/redis/RedisCacheTest.php b/tests/unit/extensions/redis/RedisCacheTest.php new file mode 100644 index 0000000..a235bb5 --- /dev/null +++ b/tests/unit/extensions/redis/RedisCacheTest.php @@ -0,0 +1,101 @@ +getParam('databases'); + $params = isset($databases['redis']) ? $databases['redis'] : null; + if ($params === null) { + $this->markTestSkipped('No redis server connection configured.'); + } + $connection = new Connection($params); + if(!@stream_socket_client($connection->hostname . ':' . $connection->port, $errorNumber, $errorDescription, 0.5)) { + $this->markTestSkipped('No redis server running at ' . $connection->hostname . ':' . $connection->port . ' : ' . $errorNumber . ' - ' . $errorDescription); + } + + $this->mockApplication(['components' => ['redis' => $connection]]); + + if ($this->_cacheInstance === null) { + $this->_cacheInstance = new Cache(); + } + return $this->_cacheInstance; + } + + public function testExpireMilliseconds() + { + $cache = $this->getCacheInstance(); + + $this->assertTrue($cache->set('expire_test_ms', 'expire_test_ms', 0.2)); + usleep(100000); + $this->assertEquals('expire_test_ms', $cache->get('expire_test_ms')); + usleep(300000); + $this->assertFalse($cache->get('expire_test_ms')); + } + + public function testExpireAddMilliseconds() + { + $cache = $this->getCacheInstance(); + + $this->assertTrue($cache->add('expire_testa_ms', 'expire_testa_ms', 0.2)); + usleep(100000); + $this->assertEquals('expire_testa_ms', $cache->get('expire_testa_ms')); + usleep(300000); + $this->assertFalse($cache->get('expire_testa_ms')); + } + + /** + * Store a value that is 2 times buffer size big + * https://github.com/yiisoft/yii2/issues/743 + */ + public function testLargeData() + { + $cache = $this->getCacheInstance(); + + $data=str_repeat('XX',8192); // http://www.php.net/manual/en/function.fread.php + $key='bigdata1'; + + $this->assertFalse($cache->get($key)); + $cache->set($key,$data); + $this->assertTrue($cache->get($key)===$data); + + // try with multibyte string + $data=str_repeat('ЖЫ',8192); // http://www.php.net/manual/en/function.fread.php + $key='bigdata2'; + + $this->assertFalse($cache->get($key)); + $cache->set($key,$data); + $this->assertTrue($cache->get($key)===$data); + } + + public function testMultiByteGetAndSet() + { + $cache = $this->getCacheInstance(); + + $data=['abc'=>'ежик',2=>'def']; + $key='data1'; + + $this->assertFalse($cache->get($key)); + $cache->set($key,$data); + $this->assertTrue($cache->get($key)===$data); + } + +} \ No newline at end of file diff --git a/tests/unit/extensions/redis/RedisConnectionTest.php b/tests/unit/extensions/redis/RedisConnectionTest.php new file mode 100644 index 0000000..7a6bd91 --- /dev/null +++ b/tests/unit/extensions/redis/RedisConnectionTest.php @@ -0,0 +1,58 @@ +getConnection(false); + $db->open(); + $this->assertTrue($db->ping()); + $db->set('YIITESTKEY', 'YIITESTVALUE'); + $db->close(); + + $db = $this->getConnection(false); + $db->database = 0; + $db->open(); + $this->assertEquals('YIITESTVALUE', $db->get('YIITESTKEY')); + $db->close(); + + $db = $this->getConnection(false); + $db->database = 1; + $db->open(); + $this->assertNull($db->get('YIITESTKEY')); + $db->close(); + } + + public function keyValueData() + { + return array( + array(123), + array(-123), + array(0), + array('test'), + array("test\r\ntest"), + array(''), + ); + } + + /** + * @dataProvider keyValueData + */ + public function testStoreGet($data) + { + $db = $this->getConnection(true); + + $db->set('hi', $data); + $this->assertEquals($data, $db->get('hi')); + } +} \ No newline at end of file diff --git a/tests/unit/extensions/redis/RedisTestCase.php b/tests/unit/extensions/redis/RedisTestCase.php new file mode 100644 index 0000000..ed4dccd --- /dev/null +++ b/tests/unit/extensions/redis/RedisTestCase.php @@ -0,0 +1,48 @@ +getParam('databases'); + $params = isset($databases['redis']) ? $databases['redis'] : null; + if ($params === null) { + $this->markTestSkipped('No redis server connection configured.'); + } + $connection = new Connection($params); + if(!@stream_socket_client($connection->hostname . ':' . $connection->port, $errorNumber, $errorDescription, 0.5)) { + $this->markTestSkipped('No redis server running at ' . $connection->hostname . ':' . $connection->port . ' : ' . $errorNumber . ' - ' . $errorDescription); + } + + $this->mockApplication(['components' => ['redis' => $connection]]); + + parent::setUp(); + } + + /** + * @param bool $reset whether to clean up the test database + * @return Connection + */ + public function getConnection($reset = true) + { + $databases = $this->getParam('databases'); + $params = isset($databases['redis']) ? $databases['redis'] : []; + $db = new Connection($params); + if ($reset) { + $db->open(); + $db->flushdb(); + } + return $db; + } +} \ No newline at end of file diff --git a/tests/unit/framework/caching/RedisCacheTest.php b/tests/unit/framework/caching/RedisCacheTest.php deleted file mode 100644 index 3201a49..0000000 --- a/tests/unit/framework/caching/RedisCacheTest.php +++ /dev/null @@ -1,95 +0,0 @@ - 'localhost', - 'port' => 6379, - 'database' => 0, - 'dataTimeout' => 0.1, - ]; - $dsn = $config['hostname'] . ':' .$config['port']; - if (!@stream_socket_client($dsn, $errorNumber, $errorDescription, 0.5)) { - $this->markTestSkipped('No redis server running at ' . $dsn .' : ' . $errorNumber . ' - ' . $errorDescription); - } - - if ($this->_cacheInstance === null) { - $this->_cacheInstance = new RedisCache($config); - } - return $this->_cacheInstance; - } - - public function testExpireMilliseconds() - { - $cache = $this->getCacheInstance(); - - $this->assertTrue($cache->set('expire_test_ms', 'expire_test_ms', 0.2)); - usleep(100000); - $this->assertEquals('expire_test_ms', $cache->get('expire_test_ms')); - usleep(300000); - $this->assertFalse($cache->get('expire_test_ms')); - } - - public function testExpireAddMilliseconds() - { - $cache = $this->getCacheInstance(); - - $this->assertTrue($cache->add('expire_testa_ms', 'expire_testa_ms', 0.2)); - usleep(100000); - $this->assertEquals('expire_testa_ms', $cache->get('expire_testa_ms')); - usleep(300000); - $this->assertFalse($cache->get('expire_testa_ms')); - } - - /** - * Store a value that is 2 times buffer size big - * https://github.com/yiisoft/yii2/issues/743 - */ - public function testLargeData() - { - $cache = $this->getCacheInstance(); - - $data=str_repeat('XX',8192); // http://www.php.net/manual/en/function.fread.php - $key='bigdata1'; - - $this->assertFalse($cache->get($key)); - $cache->set($key,$data); - $this->assertTrue($cache->get($key)===$data); - - // try with multibyte string - $data=str_repeat('ЖЫ',8192); // http://www.php.net/manual/en/function.fread.php - $key='bigdata2'; - - $this->assertFalse($cache->get($key)); - $cache->set($key,$data); - $this->assertTrue($cache->get($key)===$data); - } - - public function testMultiByteGetAndSet() - { - $cache = $this->getCacheInstance(); - - $data=['abc'=>'ежик',2=>'def']; - $key='data1'; - - $this->assertFalse($cache->get($key)); - $cache->set($key,$data); - $this->assertTrue($cache->get($key)===$data); - } - -} \ No newline at end of file diff --git a/tests/unit/framework/redis/ActiveRecordTest.php b/tests/unit/framework/redis/ActiveRecordTest.php deleted file mode 100644 index 31907f7..0000000 --- a/tests/unit/framework/redis/ActiveRecordTest.php +++ /dev/null @@ -1,466 +0,0 @@ -getConnection(); - - $customer = new Customer(); - $customer->setAttributes(['email' => 'user1@example.com', 'name' => 'user1', 'address' => 'address1', 'status' => 1], false); - $customer->save(false); - $customer = new Customer(); - $customer->setAttributes(['email' => 'user2@example.com', 'name' => 'user2', 'address' => 'address2', 'status' => 1], false); - $customer->save(false); - $customer = new Customer(); - $customer->setAttributes(['email' => 'user3@example.com', 'name' => 'user3', 'address' => 'address3', 'status' => 2], false); - $customer->save(false); - -// INSERT INTO tbl_category (name) VALUES ('Books'); -// INSERT INTO tbl_category (name) VALUES ('Movies'); - - $item = new Item(); - $item->setAttributes(['name' => 'Agile Web Application Development with Yii1.1 and PHP5', 'category_id' => 1], false); - $item->save(false); - $item = new Item(); - $item->setAttributes(['name' => 'Yii 1.1 Application Development Cookbook', 'category_id' => 1], false); - $item->save(false); - $item = new Item(); - $item->setAttributes(['name' => 'Ice Age', 'category_id' => 2], false); - $item->save(false); - $item = new Item(); - $item->setAttributes(['name' => 'Toy Story', 'category_id' => 2], false); - $item->save(false); - $item = new Item(); - $item->setAttributes(['name' => 'Cars', 'category_id' => 2], false); - $item->save(false); - - $order = new Order(); - $order->setAttributes(['customer_id' => 1, 'create_time' => 1325282384, 'total' => 110.0], false); - $order->save(false); - $order = new Order(); - $order->setAttributes(['customer_id' => 2, 'create_time' => 1325334482, 'total' => 33.0], false); - $order->save(false); - $order = new Order(); - $order->setAttributes(['customer_id' => 2, 'create_time' => 1325502201, 'total' => 40.0], false); - $order->save(false); - - $orderItem = new OrderItem(); - $orderItem->setAttributes(['order_id' => 1, 'item_id' => 1, 'quantity' => 1, 'subtotal' => 30.0], false); - $orderItem->save(false); - $orderItem = new OrderItem(); - $orderItem->setAttributes(['order_id' => 1, 'item_id' => 2, 'quantity' => 2, 'subtotal' => 40.0], false); - $orderItem->save(false); - $orderItem = new OrderItem(); - $orderItem->setAttributes(['order_id' => 2, 'item_id' => 4, 'quantity' => 1, 'subtotal' => 10.0], false); - $orderItem->save(false); - $orderItem = new OrderItem(); - $orderItem->setAttributes(['order_id' => 2, 'item_id' => 5, 'quantity' => 1, 'subtotal' => 15.0], false); - $orderItem->save(false); - $orderItem = new OrderItem(); - $orderItem->setAttributes(['order_id' => 2, 'item_id' => 3, 'quantity' => 1, 'subtotal' => 8.0], false); - $orderItem->save(false); - $orderItem = new OrderItem(); - $orderItem->setAttributes(['order_id' => 3, 'item_id' => 2, 'quantity' => 1, 'subtotal' => 40.0], false); - $orderItem->save(false); - } - - public function testFind() - { - // find one - $result = Customer::find(); - $this->assertTrue($result instanceof ActiveQuery); - $customer = $result->one(); - $this->assertTrue($customer instanceof Customer); - - // find all - $customers = Customer::find()->all(); - $this->assertEquals(3, count($customers)); - $this->assertTrue($customers[0] instanceof Customer); - $this->assertTrue($customers[1] instanceof Customer); - $this->assertTrue($customers[2] instanceof Customer); - - // find by a single primary key - $customer = Customer::find(2); - $this->assertTrue($customer instanceof Customer); - $this->assertEquals('user2', $customer->name); - $customer = Customer::find(5); - $this->assertNull($customer); - $customer = Customer::find(['id' => [5, 6, 1]]); - $this->assertEquals(1, count($customer)); - $customer = Customer::find()->where(['id' => [5, 6, 1]])->one(); - $this->assertNotNull($customer); - - // query scalar - $customerName = Customer::find()->where(['id' => 2])->scalar('name'); - $this->assertEquals('user2', $customerName); - - // find by column values - $customer = Customer::find(['id' => 2, 'name' => 'user2']); - $this->assertTrue($customer instanceof Customer); - $this->assertEquals('user2', $customer->name); - $customer = Customer::find(['id' => 2, 'name' => 'user1']); - $this->assertNull($customer); - $customer = Customer::find(['id' => 5]); - $this->assertNull($customer); - - // find by attributes - $customer = Customer::find()->where(['name' => 'user2'])->one(); - $this->assertTrue($customer instanceof Customer); - $this->assertEquals(2, $customer->id); - - // find count, sum, average, min, max, scalar - $this->assertEquals(3, Customer::find()->count()); - $this->assertEquals(6, Customer::find()->sum('id')); - $this->assertEquals(2, Customer::find()->average('id')); - $this->assertEquals(1, Customer::find()->min('id')); - $this->assertEquals(3, Customer::find()->max('id')); - - // scope - $this->assertEquals(2, Customer::find()->active()->count()); - - // asArray - $customer = Customer::find()->where(['id' => 2])->asArray()->one(); - $this->assertEquals(array( - 'id' => '2', - 'email' => 'user2@example.com', - 'name' => 'user2', - 'address' => 'address2', - 'status' => '1', - ), $customer); - - // indexBy - $customers = Customer::find()->indexBy('name')->all(); - $this->assertEquals(3, count($customers)); - $this->assertTrue($customers['user1'] instanceof Customer); - $this->assertTrue($customers['user2'] instanceof Customer); - $this->assertTrue($customers['user3'] instanceof Customer); - - // indexBy callable - $customers = Customer::find()->indexBy(function ($customer) { - return $customer->id . '-' . $customer->name; -// })->orderBy('id')->all(); - })->all(); - $this->assertEquals(3, count($customers)); - $this->assertTrue($customers['1-user1'] instanceof Customer); - $this->assertTrue($customers['2-user2'] instanceof Customer); - $this->assertTrue($customers['3-user3'] instanceof Customer); - } - - public function testFindCount() - { - $this->assertEquals(3, Customer::find()->count()); - $this->assertEquals(1, Customer::find()->limit(1)->count()); - $this->assertEquals(2, Customer::find()->limit(2)->count()); - $this->assertEquals(1, Customer::find()->offset(2)->limit(2)->count()); - } - - public function testFindLimit() - { - // all() - $customers = Customer::find()->all(); - $this->assertEquals(3, count($customers)); - - $customers = Customer::find()->limit(1)->all(); - $this->assertEquals(1, count($customers)); - $this->assertEquals('user1', $customers[0]->name); - - $customers = Customer::find()->limit(1)->offset(1)->all(); - $this->assertEquals(1, count($customers)); - $this->assertEquals('user2', $customers[0]->name); - - $customers = Customer::find()->limit(1)->offset(2)->all(); - $this->assertEquals(1, count($customers)); - $this->assertEquals('user3', $customers[0]->name); - - $customers = Customer::find()->limit(2)->offset(1)->all(); - $this->assertEquals(2, count($customers)); - $this->assertEquals('user2', $customers[0]->name); - $this->assertEquals('user3', $customers[1]->name); - - $customers = Customer::find()->limit(2)->offset(3)->all(); - $this->assertEquals(0, count($customers)); - - // one() - $customer = Customer::find()->one(); - $this->assertEquals('user1', $customer->name); - - $customer = Customer::find()->offset(0)->one(); - $this->assertEquals('user1', $customer->name); - - $customer = Customer::find()->offset(1)->one(); - $this->assertEquals('user2', $customer->name); - - $customer = Customer::find()->offset(2)->one(); - $this->assertEquals('user3', $customer->name); - - $customer = Customer::find()->offset(3)->one(); - $this->assertNull($customer); - - } - - public function testFindComplexCondition() - { - $this->assertEquals(2, Customer::find()->where(['OR', ['id' => 1], ['id' => 2]])->count()); - $this->assertEquals(2, count(Customer::find()->where(['OR', ['id' => 1], ['id' => 2]])->all())); - - $this->assertEquals(2, Customer::find()->where(['id' => [1,2]])->count()); - $this->assertEquals(2, count(Customer::find()->where(['id' => [1,2]])->all())); - - $this->assertEquals(1, Customer::find()->where(['AND', ['id' => [2,3]], ['BETWEEN', 'status', 2, 4]])->count()); - $this->assertEquals(1, count(Customer::find()->where(['AND', ['id' => [2,3]], ['BETWEEN', 'status', 2, 4]])->all())); - } - - public function testSum() - { - $this->assertEquals(6, OrderItem::find()->count()); - $this->assertEquals(7, OrderItem::find()->sum('quantity')); - } - - public function testFindColumn() - { - $this->assertEquals(['user1', 'user2', 'user3'], Customer::find()->column('name')); -// TODO $this->assertEquals(['user3', 'user2', 'user1'], Customer::find()->orderBy(['name' => SORT_DESC])->column('name')); - } - - public function testExists() - { - $this->assertTrue(Customer::find()->where(['id' => 2])->exists()); - $this->assertFalse(Customer::find()->where(['id' => 5])->exists()); - } - - public function testFindLazy() - { - /** @var $customer Customer */ - $customer = Customer::find(2); - $orders = $customer->orders; - $this->assertEquals(2, count($orders)); - - $orders = $customer->getOrders()->where(['id' => 3])->all(); - $this->assertEquals(1, count($orders)); - $this->assertEquals(3, $orders[0]->id); - } - - public function testFindEager() - { - $customers = Customer::find()->with('orders')->all(); - $this->assertEquals(3, count($customers)); - $this->assertEquals(1, count($customers[0]->orders)); - $this->assertEquals(2, count($customers[1]->orders)); - } - - public function testFindLazyVia() - { - /** @var $order Order */ - $order = Order::find(1); - $this->assertEquals(1, $order->id); - $this->assertEquals(2, count($order->items)); - $this->assertEquals(1, $order->items[0]->id); - $this->assertEquals(2, $order->items[1]->id); - - $order = Order::find(1); - $order->id = 100; - $this->assertEquals([], $order->items); - } - - public function testFindEagerViaRelation() - { - $orders = Order::find()->with('items')->all(); - $this->assertEquals(3, count($orders)); - $order = $orders[0]; - $this->assertEquals(1, $order->id); - $this->assertEquals(2, count($order->items)); - $this->assertEquals(1, $order->items[0]->id); - $this->assertEquals(2, $order->items[1]->id); - } - - public function testFindNestedRelation() - { - $customers = Customer::find()->with('orders', 'orders.items')->all(); - $this->assertEquals(3, count($customers)); - $this->assertEquals(1, count($customers[0]->orders)); - $this->assertEquals(2, count($customers[1]->orders)); - $this->assertEquals(0, count($customers[2]->orders)); - $this->assertEquals(2, count($customers[0]->orders[0]->items)); - $this->assertEquals(3, count($customers[1]->orders[0]->items)); - $this->assertEquals(1, count($customers[1]->orders[1]->items)); - } - - public function testLink() - { - $customer = Customer::find(2); - $this->assertEquals(2, count($customer->orders)); - - // has many - $order = new Order; - $order->total = 100; - $this->assertTrue($order->isNewRecord); - $customer->link('orders', $order); - $this->assertEquals(3, count($customer->orders)); - $this->assertFalse($order->isNewRecord); - $this->assertEquals(3, count($customer->getOrders()->all())); - $this->assertEquals(2, $order->customer_id); - - // belongs to - $order = new Order; - $order->total = 100; - $this->assertTrue($order->isNewRecord); - $customer = Customer::find(1); - $this->assertNull($order->customer); - $order->link('customer', $customer); - $this->assertFalse($order->isNewRecord); - $this->assertEquals(1, $order->customer_id); - $this->assertEquals(1, $order->customer->id); - - // via model - $order = Order::find(1); - $this->assertEquals(2, count($order->items)); - $this->assertEquals(2, count($order->orderItems)); - $orderItem = OrderItem::find(['order_id' => 1, 'item_id' => 3]); - $this->assertNull($orderItem); - $item = Item::find(3); - $order->link('items', $item, ['quantity' => 10, 'subtotal' => 100]); - $this->assertEquals(3, count($order->items)); - $this->assertEquals(3, count($order->orderItems)); - $orderItem = OrderItem::find(['order_id' => 1, 'item_id' => 3]); - $this->assertTrue($orderItem instanceof OrderItem); - $this->assertEquals(10, $orderItem->quantity); - $this->assertEquals(100, $orderItem->subtotal); - } - - public function testUnlink() - { - // has many - $customer = Customer::find(2); - $this->assertEquals(2, count($customer->orders)); - $customer->unlink('orders', $customer->orders[1], true); - $this->assertEquals(1, count($customer->orders)); - $this->assertNull(Order::find(3)); - - // via model - $order = Order::find(2); - $this->assertEquals(3, count($order->items)); - $this->assertEquals(3, count($order->orderItems)); - $order->unlink('items', $order->items[2], true); - $this->assertEquals(2, count($order->items)); - $this->assertEquals(2, count($order->orderItems)); - } - - public function testInsert() - { - $customer = new Customer; - $customer->email = 'user4@example.com'; - $customer->name = 'user4'; - $customer->address = 'address4'; - - $this->assertNull($customer->id); - $this->assertTrue($customer->isNewRecord); - - $customer->save(); - - $this->assertEquals(4, $customer->id); - $this->assertFalse($customer->isNewRecord); - } - - // TODO test serial column incr - - public function testUpdate() - { - // save - $customer = Customer::find(2); - $this->assertTrue($customer instanceof Customer); - $this->assertEquals('user2', $customer->name); - $this->assertFalse($customer->isNewRecord); - $customer->name = 'user2x'; - $customer->save(); - $this->assertEquals('user2x', $customer->name); - $this->assertFalse($customer->isNewRecord); - $customer2 = Customer::find(2); - $this->assertEquals('user2x', $customer2->name); - - // updateAll - $customer = Customer::find(3); - $this->assertEquals('user3', $customer->name); - $ret = Customer::updateAll(array( - 'name' => 'temp', - ), ['id' => 3]); - $this->assertEquals(1, $ret); - $customer = Customer::find(3); - $this->assertEquals('temp', $customer->name); - } - - public function testUpdateCounters() - { - // updateCounters - $pk = ['order_id' => 2, 'item_id' => 4]; - $orderItem = OrderItem::find($pk); - $this->assertEquals(1, $orderItem->quantity); - $ret = $orderItem->updateCounters(['quantity' => -1]); - $this->assertTrue($ret); - $this->assertEquals(0, $orderItem->quantity); - $orderItem = OrderItem::find($pk); - $this->assertEquals(0, $orderItem->quantity); - - // updateAllCounters - $pk = ['order_id' => 1, 'item_id' => 2]; - $orderItem = OrderItem::find($pk); - $this->assertEquals(2, $orderItem->quantity); - $ret = OrderItem::updateAllCounters(array( - 'quantity' => 3, - 'subtotal' => -10, - ), $pk); - $this->assertEquals(1, $ret); - $orderItem = OrderItem::find($pk); - $this->assertEquals(5, $orderItem->quantity); - $this->assertEquals(30, $orderItem->subtotal); - } - - public function testUpdatePk() - { - // updateCounters - $pk = ['order_id' => 2, 'item_id' => 4]; - $orderItem = OrderItem::find($pk); - $this->assertEquals(2, $orderItem->order_id); - $this->assertEquals(4, $orderItem->item_id); - - $orderItem->order_id = 2; - $orderItem->item_id = 10; - $orderItem->save(); - - $this->assertNull(OrderItem::find($pk)); - $this->assertNotNull(OrderItem::find(['order_id' => 2, 'item_id' => 10])); - } - - public function testDelete() - { - // delete - $customer = Customer::find(2); - $this->assertTrue($customer instanceof Customer); - $this->assertEquals('user2', $customer->name); - $customer->delete(); - $customer = Customer::find(2); - $this->assertNull($customer); - - // deleteAll - $customers = Customer::find()->all(); - $this->assertEquals(2, count($customers)); - $ret = Customer::deleteAll(); - $this->assertEquals(2, $ret); - $customers = Customer::find()->all(); - $this->assertEquals(0, count($customers)); - } -} \ No newline at end of file diff --git a/tests/unit/framework/redis/RedisConnectionTest.php b/tests/unit/framework/redis/RedisConnectionTest.php deleted file mode 100644 index af39e0e..0000000 --- a/tests/unit/framework/redis/RedisConnectionTest.php +++ /dev/null @@ -1,69 +0,0 @@ -open(); - } - - /** - * test connection to redis and selection of db - */ - public function testConnect() - { - $db = new Connection(); - $db->dsn = 'redis://localhost:6379'; - $db->open(); - $this->assertTrue($db->ping()); - $db->set('YIITESTKEY', 'YIITESTVALUE'); - $db->close(); - - $db = new Connection(); - $db->dsn = 'redis://localhost:6379/0'; - $db->open(); - $this->assertEquals('YIITESTVALUE', $db->get('YIITESTKEY')); - $db->close(); - - $db = new Connection(); - $db->dsn = 'redis://localhost:6379/1'; - $db->open(); - $this->assertNull($db->get('YIITESTKEY')); - $db->close(); - } - - public function keyValueData() - { - return array( - array(123), - array(-123), - array(0), - array('test'), - array("test\r\ntest"), - array(''), - ); - } - - /** - * @dataProvider keyValueData - */ - public function testStoreGet($data) - { - $db = $this->getConnection(true); - - $db->set('hi', $data); - $this->assertEquals($data, $db->get('hi')); - } -} \ No newline at end of file diff --git a/tests/unit/framework/redis/RedisTestCase.php b/tests/unit/framework/redis/RedisTestCase.php deleted file mode 100644 index 12e539d..0000000 --- a/tests/unit/framework/redis/RedisTestCase.php +++ /dev/null @@ -1,51 +0,0 @@ -mockApplication(); - - $databases = $this->getParam('databases'); - $params = isset($databases['redis']) ? $databases['redis'] : null; - if ($params === null || !isset($params['dsn'])) { - $this->markTestSkipped('No redis server connection configured.'); - } - $dsn = explode('/', $params['dsn']); - $host = $dsn[2]; - if (strpos($host, ':')===false) { - $host .= ':6379'; - } - if(!@stream_socket_client($host, $errorNumber, $errorDescription, 0.5)) { - $this->markTestSkipped('No redis server running at ' . $params['dsn'] . ' : ' . $errorNumber . ' - ' . $errorDescription); - } - - parent::setUp(); - } - - /** - * @param bool $reset whether to clean up the test database - * @return Connection - */ - public function getConnection($reset = true) - { - $databases = $this->getParam('databases'); - $params = isset($databases['redis']) ? $databases['redis'] : array(); - $db = new Connection; - $db->dsn = $params['dsn']; - $db->password = $params['password']; - if ($reset) { - $db->open(); - $db->flushall(); - } - return $db; - } -} \ No newline at end of file From e837e44ac130d376d9168a32cf4f421794e79266 Mon Sep 17 00:00:00 2001 From: Carsten Brandt Date: Mon, 25 Nov 2013 02:31:23 +0100 Subject: [PATCH 59/59] updated ext composer.json for issue labels --- extensions/bootstrap/composer.json | 2 +- extensions/debug/composer.json | 2 +- extensions/gii/composer.json | 2 +- extensions/jui/composer.json | 2 +- extensions/smarty/composer.json | 2 +- extensions/sphinx/composer.json | 2 +- extensions/swiftmailer/composer.json | 2 +- extensions/twig/composer.json | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/extensions/bootstrap/composer.json b/extensions/bootstrap/composer.json index e80de80..3e6031e 100644 --- a/extensions/bootstrap/composer.json +++ b/extensions/bootstrap/composer.json @@ -5,7 +5,7 @@ "type": "yii2-extension", "license": "BSD-3-Clause", "support": { - "issues": "https://github.com/yiisoft/yii2/issues?state=open", + "issues": "https://github.com/yiisoft/yii2/issues?labels=ext%3Abootstrap", "forum": "http://www.yiiframework.com/forum/", "wiki": "http://www.yiiframework.com/wiki/", "irc": "irc://irc.freenode.net/yii", diff --git a/extensions/debug/composer.json b/extensions/debug/composer.json index f60d1df..d8cbc1e 100644 --- a/extensions/debug/composer.json +++ b/extensions/debug/composer.json @@ -5,7 +5,7 @@ "type": "yii2-extension", "license": "BSD-3-Clause", "support": { - "issues": "https://github.com/yiisoft/yii2/issues?state=open", + "issues": "https://github.com/yiisoft/yii2/issues?labels=ext%3Adebug", "forum": "http://www.yiiframework.com/forum/", "wiki": "http://www.yiiframework.com/wiki/", "irc": "irc://irc.freenode.net/yii", diff --git a/extensions/gii/composer.json b/extensions/gii/composer.json index 8654621..4e17844 100644 --- a/extensions/gii/composer.json +++ b/extensions/gii/composer.json @@ -5,7 +5,7 @@ "type": "yii2-extension", "license": "BSD-3-Clause", "support": { - "issues": "https://github.com/yiisoft/yii2/issues?state=open", + "issues": "https://github.com/yiisoft/yii2/issues?labels=ext%3Agii", "forum": "http://www.yiiframework.com/forum/", "wiki": "http://www.yiiframework.com/wiki/", "irc": "irc://irc.freenode.net/yii", diff --git a/extensions/jui/composer.json b/extensions/jui/composer.json index ff54422..a8b9559 100644 --- a/extensions/jui/composer.json +++ b/extensions/jui/composer.json @@ -5,7 +5,7 @@ "type": "yii2-extension", "license": "BSD-3-Clause", "support": { - "issues": "https://github.com/yiisoft/yii2/issues?state=open", + "issues": "https://github.com/yiisoft/yii2/issues?labels=ext%3Ajui", "forum": "http://www.yiiframework.com/forum/", "wiki": "http://www.yiiframework.com/wiki/", "irc": "irc://irc.freenode.net/yii", diff --git a/extensions/smarty/composer.json b/extensions/smarty/composer.json index 88b75a3..a3a8254 100644 --- a/extensions/smarty/composer.json +++ b/extensions/smarty/composer.json @@ -5,7 +5,7 @@ "type": "yii2-extension", "license": "BSD-3-Clause", "support": { - "issues": "https://github.com/yiisoft/yii2/issues?state=open", + "issues": "https://github.com/yiisoft/yii2/issues?labels=ext%3Asmarty", "forum": "http://www.yiiframework.com/forum/", "wiki": "http://www.yiiframework.com/wiki/", "irc": "irc://irc.freenode.net/yii", diff --git a/extensions/sphinx/composer.json b/extensions/sphinx/composer.json index decac2f..9a323d7 100644 --- a/extensions/sphinx/composer.json +++ b/extensions/sphinx/composer.json @@ -5,7 +5,7 @@ "type": "yii2-extension", "license": "BSD-3-Clause", "support": { - "issues": "https://github.com/yiisoft/yii2/issues?state=open", + "issues": "https://github.com/yiisoft/yii2/issues?labels=ext%3Asphinx", "forum": "http://www.yiiframework.com/forum/", "wiki": "http://www.yiiframework.com/wiki/", "irc": "irc://irc.freenode.net/yii", diff --git a/extensions/swiftmailer/composer.json b/extensions/swiftmailer/composer.json index 5a47397..0d0953b 100644 --- a/extensions/swiftmailer/composer.json +++ b/extensions/swiftmailer/composer.json @@ -5,7 +5,7 @@ "type": "yii2-extension", "license": "BSD-3-Clause", "support": { - "issues": "https://github.com/yiisoft/yii2/issues?state=open", + "issues": "https://github.com/yiisoft/yii2/issues?labels=ext%3Aswiftmailer", "forum": "http://www.yiiframework.com/forum/", "wiki": "http://www.yiiframework.com/wiki/", "irc": "irc://irc.freenode.net/yii", diff --git a/extensions/twig/composer.json b/extensions/twig/composer.json index 8fe6431..1e7f49e 100644 --- a/extensions/twig/composer.json +++ b/extensions/twig/composer.json @@ -5,7 +5,7 @@ "type": "yii2-extension", "license": "BSD-3-Clause", "support": { - "issues": "https://github.com/yiisoft/yii2/issues?state=open", + "issues": "https://github.com/yiisoft/yii2/issues?labels=ext%3Atwig", "forum": "http://www.yiiframework.com/forum/", "wiki": "http://www.yiiframework.com/wiki/", "irc": "irc://irc.freenode.net/yii",