├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── UPGRADE.md ├── composer.json └── src ├── ActiveDataProvider.php ├── ActiveFixture.php ├── ActiveQuery.php ├── ActiveRecord.php ├── ColumnSchema.php ├── Command.php ├── Connection.php ├── IndexSchema.php ├── MatchBuilder.php ├── MatchExpression.php ├── Query.php ├── QueryBuilder.php ├── Schema.php └── gii └── model ├── Generator.php ├── default └── model.php └── form.php /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | Yii Framework 2 sphinx extension Change Log 2 | =========================================== 3 | 4 | 2.0.17 under development 5 | ------------------------ 6 | 7 | - no changes in this release. 8 | 9 | 10 | 2.0.16 March 21, 2024 11 | --------------------- 12 | 13 | - Enh #144: Add `UINT_SET` attribute type support (@vjik) 14 | 15 | 16 | 2.0.15 November 18, 2022 17 | ------------------------ 18 | 19 | - Bug #127: Fixed warning "Undefined array key" in Query at facets collecting when moving to PHP 8.1 (kulikov6) 20 | 21 | 22 | 2.0.14 December 30, 2021 23 | ------------------------ 24 | 25 | - Enh #123: Added implementation of methods for creating, deleting and updating real-time index schema for Sphinx version 3 (troizet) 26 | 27 | 28 | 2.0.13 February 22, 2020 29 | ------------------------ 30 | 31 | - Bug #114: Fixes exception "Call to a member function getSnippetSource() on array" when call `\yii\sphinx\ActiveQuery::search` broken by v2.0.11 (miketsoft) 32 | 33 | 34 | 2.0.12 July 02, 2019 35 | -------------------- 36 | 37 | - Bug #111: Fixed SQL error when running Yii 2.0.21+ (brandonkelly) 38 | 39 | 40 | 2.0.11 September 23, 2018 41 | ------------------------- 42 | 43 | - Bug #101: Fixed `yii\sphinx\ActiveQuery::all()` unable to follow instructions given by method `indexBy()` (bitdevelopment) 44 | 45 | 46 | 2.0.10 February 13, 2018 47 | ------------------------ 48 | 49 | - Bug #90: Fixed `yii\sphinx\Schema::findColumns()` unable to merge 'field' and 'attribute' columns with same name (maz0717, klimov-paul) 50 | - Bug #92: Fixed `yii\sphinx\QueryBuilder::buildInCondition()` incompatibility with PHP 7.2 (klimov-paul) 51 | - Enh #93: `yii\sphinx\QueryBuilder::callSnippets()` now automatically casts snippet source to string (klimov-paul) 52 | - Enh: `yii\sphinx\QueryBuilder` now supports `Traversable` objects for use in `in` conditions (klimov-paul) 53 | 54 | 55 | 2.0.9 November 03, 2017 56 | ----------------------- 57 | 58 | - Bug #75: Fixed empty MVA field value is fetches as array with null element instead of empty array (batyrmastyr, klimov-paul) 59 | - Bug #78: Fixed `yii\sphinx\Query::where()` does not add params from directly passed `yii\db\Expression` (klimov-paul) 60 | - Bug #82: Fixed `yii\sphinx\Query::select()` does not apply alias for `yii\db\Expression` value (klimov-paul) 61 | - Bug #83: Fixed `yii\sphinx\ActiveRecord::update()` causes attribute value lost in case of field update (klimov-paul) 62 | - Bug #87: Fixed `yii\sphinx\Command::getRawSql()` does not parse float params (klimov-paul) 63 | - Bug: Usage of deprecated `yii\base\Object` changed to `yii\base\BaseObject` allowing compatibility with PHP 7.2 (klimov-paul) 64 | - Enh #73: `isRuntime` field of `yii\sphinx\IndexSchema` renamed to `isRt` for consistency with official docs (klimov-paul) 65 | 66 | 67 | 2.0.8 May 15, 2017 68 | ------------------ 69 | 70 | - Bug #71: Fixed PHP type for `sql_attr_timestamp` attribute incorrectly detected as string (klimov-paul) 71 | 72 | 73 | 2.0.7 February 15, 2017 74 | ----------------------- 75 | 76 | - Bug #69: Fixed `yii\sphinx\Query::andFilterWhere()` quotes integer column value in case comparison operator is used (klimov-paul) 77 | - Enh #67: Added support for `yii\db\QueryInterface::emulateExecution()` to force returning an empty result for a query (klimov-paul) 78 | - Enh #70: Added `filterMatch()` method to `yii\sphinx\MatchExpression` to allow easy addition of search filter conditions by ignoring empty search fields (klimov-paul) 79 | 80 | 81 | 2.0.6 September 02, 2016 82 | ------------------------ 83 | 84 | - Bug #8: Fixed usage of the float values in SphinxQL bound params (klimov-paul) 85 | - Bug #45: Fixed `yii\sphinx\Schema` unable to determine primary key for distribute index (klimov-paul) 86 | - Bug #61: Fixed `yii\sphinx\QueryBuilder::callSnippets()` unable to handle 'match' specified as `yii\db\Expression` instance (klimov-paul) 87 | - Enh #26: Added `yii\sphinx\Query::groupLimit` allowing limit matches in 'group by' (klimov-paul) 88 | - Enh #53: Added `yii\sphinx\MatchExpression` allowing advanced composition of 'MATCH' expressions (sa-kirich, klimov-paul) 89 | - Enh #56: `yii\sphinx\Schema` now able to get schema for distributed index in case at least one (not only the first one) of local indexes is available (kdietrich, klimov-paul) 90 | - Enh #58: Added fallback for distributed index columns detection at `yii\sphinx\Schema` (klimov-paul) 91 | 92 | 93 | 2.0.5 September 23, 2015 94 | ------------------------ 95 | 96 | - Bug #13: Fixed `yii\sphinx\ActiveDataProvider` breaks the pagination if `yii\data\Pagination::validatePage` enabled even, if `yii\sphinx\Query::showMeta` is not set (klimov-paul) 97 | - Bug #21: `yii\sphinx\Query` unable to retrieve facet named in camel-case notation (klimov-paul) 98 | - Bug #27: Fixed `yii\sphinx\ActiveQuery::search()` produces 'unbuffered query' error if 'facet' or 'show meta' are used (klimov-paul) 99 | - Bug #30: Fixed `yii\sphinx\ActiveQuery` does not perform typecast for condition values (klimov-paul) 100 | - Bug #31: Fixed `yii\sphinx\QueryBuilder::buildInCondition()` fails produces invalid SphinxQL for empty values (klimov-paul) 101 | - Bug #43: Fixed `yii\sphinx\QueryBuilder::buildWithin()` does not define sort order for `SORT_ASC` (klimov-paul) 102 | - Enh #11: `yii\sphinx\ActiveDataProvider` now disables `yii\data\Pagination::validatePage` automatically if `yii\sphinx\Query::showMeta` is set (klimov-paul) 103 | - Enh #11: `yii\sphinx\ActiveDataProvider` now disables `yii\data\Pagination::validatePage` automatically if `yii\sphinx\Query::showMeta` is set (klimov-paul) 104 | - Enh #17: Using total_found instead of total in `yii\sphinx\ActiveDataProvider::prepareTotalCount` (lmuzinic) 105 | - Enh #29: Added `yii\sphinx\Command` automatically skips `null` values while inserting data (klimov-paul) 106 | 107 | 108 | 2.0.4 May 10, 2015 109 | ------------------ 110 | 111 | - Enh: Fetching 'SHOW META' info added to `yii\sphinx\Query` (klimov-paul) 112 | - Enh #2053: Added fixture support via `yii\sphinx\ActiveFixture` (klimov-paul) 113 | - Enh #5234: Facets fetching added to `yii\sphinx\Query` (klimov-paul) 114 | 115 | 116 | 2.0.3 March 01, 2015 117 | -------------------- 118 | 119 | - Bug #7198: `yii\sphinx\Query` no longer attempts to call snippets for the empty query result set (Hrumpa) 120 | 121 | 122 | 2.0.2 January 11, 2015 123 | ---------------------- 124 | 125 | - Bug #6621: Creating sub query at `yii\sphinx\Query::queryScalar()` fixed (klimov-paul) 126 | 127 | 128 | 2.0.1 December 07, 2014 129 | ----------------------- 130 | 131 | - Bug #5601: Simple conditions in Query::where() and ActiveQuery::where() did not allow `yii\db\Expression` to be used as the value (cebe, stevekr) 132 | - Bug #5634: Fixed `yii\sphinx\QueryBuilder` does not support comparison operators (>,<,>= etc) in where specification (klimov-paul) 133 | - Bug #6164: Added missing support for `yii\db\Exression` to QueryBuilder `LIKE` conditions (cebe) 134 | - Enh #5223: Query builder now supports selecting sub-queries as columns (qiangxue) 135 | 136 | 137 | 2.0.0 October 12, 2014 138 | ---------------------- 139 | 140 | - Enh #5211: `yii\sphinx\Query` now supports 'HAVING' (klimov-paul) 141 | 142 | 143 | 2.0.0-rc September 27, 2014 144 | --------------------------- 145 | 146 | - Bug #3668: Escaping of the special characters at 'MATCH' statement added (klimov-paul) 147 | - Bug #4018: AR relation eager loading does not work with db models (klimov-paul) 148 | - Bug #4375: Distributed indexes support provided (klimov-paul) 149 | - Bug #4830: `ActiveQuery` instance reusage ability granted (klimov-paul) 150 | - Enh #3520: Added `unlinkAll()`-method to active record to remove all records of a model relation (NmDimas, samdark, cebe) 151 | - Enh #4048: Added `init` event to `ActiveQuery` classes (qiangxue) 152 | - Enh #4086: changedAttributes of afterSave Event now contain old values (dizews) 153 | - Enh: Added support for using sub-queries when building a DB query with `IN` condition (qiangxue) 154 | - Chg #2287: Split `yii\sphinx\ColumnSchema::typecast()` into two methods `phpTypecast()` and `dbTypecast()` to allow specifying PDO type explicitly (cebe) 155 | 156 | 157 | 2.0.0-beta April 13, 2014 158 | ------------------------- 159 | 160 | - Bug #1993: afterFind event in AR is now called after relations have been populated (cebe, creocoder) 161 | - Bug #2160: SphinxQL does not support `OFFSET` (qiangxue, romeo7) 162 | - Enh #1398: Refactor ActiveRecord to use BaseActiveRecord class of the framework (klimov-paul) 163 | - Enh #2002: Added filterWhere() method to yii\spinx\Query to allow easy addition of search filter conditions by ignoring empty search fields (samdark, cebe) 164 | - Enh #2892: ActiveRecord dirty attributes are now reset after call to `afterSave()` so information about changed attributes is available in `afterSave`-event (cebe) 165 | - Enh #3964: Gii generator for Active Record model added (klimov-paul) 166 | - Chg #2281: Renamed `ActiveRecord::create()` to `populateRecord()` and changed signature. This method will not call instantiate() anymore (cebe) 167 | - Chg #2146: Removed `ActiveRelation` class and moved the functionality to `ActiveQuery`. 168 | All relational queries are now directly served by `ActiveQuery` allowing to use 169 | custom scopes in relations (cebe) 170 | 171 | 172 | 2.0.0-alpha, December 1, 2013 173 | ----------------------------- 174 | 175 | - Initial release. 176 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright © 2008-2013 by Yii Software LLC (http://www.yiisoft.com) 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions 6 | are met: 7 | 8 | * Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | * Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in 12 | the documentation and/or other materials provided with the 13 | distribution. 14 | * Neither the name of Yii Software LLC nor the names of its 15 | contributors may be used to endorse or promote products derived 16 | from this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 19 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 20 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS 21 | FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 22 | COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 23 | INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 24 | BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 25 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 27 | LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN 28 | ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 29 | POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | 4 | 5 |

Sphinx Extension for Yii 2

6 |
7 |

8 | 9 | This extension adds [Sphinx](https://sphinxsearch.com/docs) full text search engine extension for the [Yii framework 2.0](https://www.yiiframework.com). 10 | It supports all Sphinx features including [Real-time Indexes](https://sphinxsearch.com/docs/current.html#rt-indexes). 11 | 12 | For license information check the [LICENSE](LICENSE.md)-file. 13 | 14 | Documentation is at [docs/guide/README.md](docs/guide/README.md). 15 | 16 | [![Latest Stable Version](https://poser.pugx.org/yiisoft/yii2-sphinx/v/stable.png)](https://packagist.org/packages/yiisoft/yii2-sphinx) 17 | [![Total Downloads](https://poser.pugx.org/yiisoft/yii2-sphinx/downloads.png)](https://packagist.org/packages/yiisoft/yii2-sphinx) 18 | [![Build status](https://github.com/yiisoft/yii2-sphinx/workflows/build/badge.svg)](https://github.com/yiisoft/yii2-sphinx/actions?query=workflow%3Abuild) 19 | 20 | 21 | Requirements 22 | ------------ 23 | 24 | At least Sphinx version 2.0 is required. However, in order to use all extension features, Sphinx version 2.2.3 or 25 | higher is required. 26 | 27 | 28 | Installation 29 | ------------ 30 | 31 | The preferred way to install this extension is through [composer](https://getcomposer.org/download/). 32 | 33 | Either run 34 | 35 | ``` 36 | php composer.phar require --prefer-dist yiisoft/yii2-sphinx 37 | ``` 38 | 39 | or add 40 | 41 | ```json 42 | "yiisoft/yii2-sphinx": "~2.0.0" 43 | ``` 44 | 45 | to the require section of your composer.json. 46 | 47 | 48 | Configuration 49 | ------------- 50 | 51 | This extension interacts with Sphinx search daemon using MySQL protocol and [SphinxQL](https://sphinxsearch.com/docs/current.html#sphinxql) query language. 52 | In order to setup Sphinx "searchd" to support MySQL protocol following configuration should be added: 53 | 54 | ``` 55 | searchd 56 | { 57 | listen = localhost:9306:mysql41 58 | ... 59 | } 60 | ``` 61 | 62 | To use this extension, simply add the following code in your application configuration: 63 | 64 | ```php 65 | return [ 66 | //.... 67 | 'components' => [ 68 | 'sphinx' => [ 69 | 'class' => 'yii\sphinx\Connection', 70 | 'dsn' => 'mysql:host=127.0.0.1;port=9306;', 71 | 'username' => '', 72 | 'password' => '', 73 | ], 74 | ], 75 | ]; 76 | ``` 77 | -------------------------------------------------------------------------------- /UPGRADE.md: -------------------------------------------------------------------------------- 1 | Upgrading Instructions for Yii Framework v2 Sphinx Extension 2 | ============================================================ 3 | 4 | !!!IMPORTANT!!! 5 | 6 | The following upgrading instructions are cumulative. That is, 7 | if you want to upgrade from version A to version C and there is 8 | version B between A and C, you need to following the instructions 9 | for both A and B. 10 | 11 | Upgrade from Yii 2.0.5 12 | ---------------------- 13 | 14 | * The signature of the `\yii\sphinx\QueryBuilder::buildGroupBy()` method has been changed. 15 | Make sure you invoke this method correctly. In case you are extending related class and override this method, 16 | you should check, if it matches parent declaration. 17 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "yiisoft/yii2-sphinx", 3 | "description": "Sphinx full text search engine extension for the Yii framework", 4 | "keywords": ["yii2", "sphinx", "active-record", "search", "fulltext"], 5 | "type": "yii2-extension", 6 | "license": "BSD-3-Clause", 7 | "support": { 8 | "issues": "https://github.com/yiisoft/yii2-sphinx/issues", 9 | "forum": "https://www.yiiframework.com/forum/", 10 | "wiki": "https://www.yiiframework.com/wiki/", 11 | "irc": "ircs://irc.libera.chat:6697/yii", 12 | "source": "https://github.com/yiisoft/yii2-sphinx" 13 | }, 14 | "authors": [ 15 | { 16 | "name": "Paul Klimov", 17 | "email": "klimov.paul@gmail.com" 18 | } 19 | ], 20 | "require": { 21 | "yiisoft/yii2": "~2.0.13", 22 | "ext-pdo": "*", 23 | "ext-pdo_mysql": "*" 24 | }, 25 | "require-dev": { 26 | "phpunit/phpunit": "4.8.27|~5.7.21|^6.2" 27 | }, 28 | "repositories": [ 29 | { 30 | "type": "composer", 31 | "url": "https://asset-packagist.org" 32 | } 33 | ], 34 | "autoload": { 35 | "psr-4": { "yii\\sphinx\\": "src" } 36 | }, 37 | "extra": { 38 | "branch-alias": { 39 | "dev-master": "2.0.x-dev" 40 | } 41 | }, 42 | "config": { 43 | "sort-packages": true, 44 | "allow-plugins": { 45 | "yiisoft/yii2-composer": true 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/ActiveDataProvider.php: -------------------------------------------------------------------------------- 1 | Post::find()->facets(['author_id', 'category_id']), 22 | * 'pagination' => [ 23 | * 'pageSize' => 20, 24 | * ], 25 | * ]); 26 | * 27 | * // get the posts in the current page 28 | * $posts = $provider->getModels(); 29 | * 30 | * // get all facets 31 | * $facets = $provider->getFacets(); 32 | * 33 | * // get particular facet 34 | * $authorFacet = $provider->getFacet('author_id'); 35 | * ``` 36 | * 37 | * In case [[Query::showMeta]] is set ActiveDataProvider will fetch total count value from the query meta information, 38 | * avoiding extra counting query: 39 | * 40 | * ```php 41 | * $provider = new ActiveDataProvider([ 42 | * 'query' => Post::find()->showMeta(true), 43 | * 'pagination' => [ 44 | * 'pageSize' => 20, 45 | * ], 46 | * ]); 47 | * 48 | * $totalCount = $provider->getTotalCount(); // fetched from meta information 49 | * ``` 50 | * 51 | * Note: when using 'meta' information results total count will be fetched after pagination limit applying, 52 | * which eliminates ability to verify if requested page number actually exist. Data provider disables `yii\data\Pagination::validatePage` 53 | * automatically in this case. 54 | * 55 | * Note: because pagination offset and limit may exceed Sphinx 'max_matches' bounds, data provider will set 'max_matches' 56 | * option automatically based on those values. However, if [[Query::showMeta]] is set, such adjustment is not performed 57 | * as it will break total count calculation, so you'll have to deal with 'max_matches' bounds on your own. 58 | * 59 | * @property array $facets Query facet results. 60 | * @property array $meta Search query meta info. 61 | * 62 | * @author Paul Klimov 63 | * @since 2.0.4 64 | */ 65 | class ActiveDataProvider extends \yii\data\ActiveDataProvider 66 | { 67 | /** 68 | * @var array search query meta info in format: name => value. 69 | */ 70 | private $_meta; 71 | /** 72 | * @var array query facet results. 73 | */ 74 | private $_facets; 75 | 76 | 77 | /** 78 | * @param array $facets query facet results. 79 | */ 80 | public function setFacets($facets) 81 | { 82 | $this->_facets = $facets; 83 | } 84 | 85 | /** 86 | * @return array query facet results. 87 | */ 88 | public function getFacets() 89 | { 90 | if (!is_array($this->_facets)) { 91 | $this->prepare(); 92 | } 93 | return $this->_facets; 94 | } 95 | 96 | /** 97 | * @param array $meta search query meta info 98 | */ 99 | public function setMeta($meta) 100 | { 101 | $this->_meta = $meta; 102 | } 103 | 104 | /** 105 | * @return array search query meta info 106 | */ 107 | public function getMeta() 108 | { 109 | if (!is_array($this->_meta)) { 110 | $this->prepare(); 111 | } 112 | return $this->_meta; 113 | } 114 | 115 | /** 116 | * Returns results of the specified facet. 117 | * @param string $name facet name 118 | * @throws InvalidCallException if requested facet does not present in results. 119 | * @return array facet results. 120 | */ 121 | public function getFacet($name) 122 | { 123 | $facets = $this->getFacets(); 124 | if (!isset($facets[$name])) { 125 | throw new InvalidCallException("Facet '{$name}' does not present."); 126 | } 127 | return $facets[$name]; 128 | } 129 | 130 | /** 131 | * {@inheritdoc} 132 | */ 133 | protected function prepareModels() 134 | { 135 | if (!$this->query instanceof Query) { 136 | throw new InvalidConfigException('The "query" property must be an instance "' . Query::className() . '" or its subclasses.'); 137 | } 138 | $query = clone $this->query; 139 | if (($pagination = $this->getPagination()) !== false) { 140 | if (empty($query->showMeta)) { 141 | $pagination->totalCount = $this->getTotalCount(); 142 | $limit = $pagination->getLimit(); 143 | $offset = $pagination->getOffset(); 144 | // pagination may exceed 'max_matches' boundary producing query error 145 | if (!isset($query->options['max_matches'])) { 146 | $query->options['max_matches'] = $offset + $limit; 147 | } 148 | $query->limit($limit)->offset($offset); 149 | } else { 150 | // pagination fails to validate page number, if total count is unknown at this stage 151 | $pagination->validatePage = false; 152 | $query->limit($pagination->getLimit())->offset($pagination->getOffset()); 153 | } 154 | } 155 | if (($sort = $this->getSort()) !== false) { 156 | $query->addOrderBy($sort->getOrders()); 157 | } 158 | 159 | $results = $query->search($this->db); 160 | $this->setMeta($results['meta']); 161 | $this->setFacets($results['facets']); 162 | 163 | if ($pagination !== false) { 164 | $pagination->totalCount = $this->getTotalCount(); 165 | } 166 | 167 | return $results['hits']; 168 | } 169 | 170 | /** 171 | * {@inheritdoc} 172 | */ 173 | protected function prepareTotalCount() 174 | { 175 | if (!$this->query instanceof Query) { 176 | throw new InvalidConfigException('The "query" property must be an instance "' . Query::className() . '" or its subclasses.'); 177 | } 178 | 179 | if (!empty($this->query->showMeta)) { 180 | $meta = $this->getMeta(); 181 | 182 | if (isset($meta['total_found'])) { 183 | return (int) $meta['total_found']; 184 | } 185 | 186 | if (isset($meta['total'])) { 187 | return (int) $meta['total']; 188 | } 189 | } 190 | 191 | $query = clone $this->query; 192 | return (int) $query->limit(-1)->offset(-1)->orderBy([])->facets([])->showMeta(false)->count('*', $this->db); 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /src/ActiveFixture.php: -------------------------------------------------------------------------------- 1 | 31 | * @since 2.0.4 32 | */ 33 | class ActiveFixture extends BaseActiveFixture 34 | { 35 | /** 36 | * @var Connection|array|string the Sphinx connection object or the application component ID of the Sphinx connection 37 | * or a configuration array for creating the object. 38 | */ 39 | public $db = 'sphinx'; 40 | /** 41 | * @var string the name of the Sphinx index that this fixture is about. If this property is not set, 42 | * the index name will be determined via [[modelClass]]. 43 | * @see modelClass 44 | */ 45 | public $indexName; 46 | /** 47 | * @var string|bool the file path or path alias of the data file that contains the fixture data 48 | * to be returned by [[getData()]]. If this is not set, it will default to `FixturePath/data/IndexName.php`, 49 | * where `FixturePath` stands for the directory containing this fixture class, and `IndexName` stands for the 50 | * name of the index associated with this fixture. You can set this property to be false to prevent loading any data. 51 | */ 52 | public $dataFile; 53 | 54 | /** 55 | * @var IndexSchema the schema for the index associated with this fixture 56 | */ 57 | private $_index; 58 | 59 | 60 | /** 61 | * {@inheritdoc} 62 | */ 63 | public function init() 64 | { 65 | parent::init(); 66 | if (!isset($this->modelClass) && !isset($this->indexName)) { 67 | throw new InvalidConfigException('Either "modelClass" or "indexName" must be set.'); 68 | } 69 | } 70 | 71 | /** 72 | * Loads the fixture. 73 | * 74 | * The default implementation will first clean up the table by calling [[resetIndex()]]. 75 | * It will then populate the index with the data returned by [[getData()]]. 76 | * 77 | * If you override this method, you should consider calling the parent implementation 78 | * so that the data returned by [[getData()]] can be populated into the index. 79 | */ 80 | public function load() 81 | { 82 | $this->resetIndex(); 83 | $this->data = []; 84 | $index = $this->getIndexSchema(); 85 | foreach ($this->getData() as $alias => $row) { 86 | $this->db->createCommand()->insert($index->name, $row)->execute(); 87 | $this->data[$alias] = $row; 88 | } 89 | } 90 | 91 | /** 92 | * Returns the fixture data. 93 | * 94 | * The default implementation will try to return the fixture data by including the external file specified by [[dataFile]]. 95 | * The file should return an array of data rows (column name => column value), each corresponding to a row in the index. 96 | * 97 | * If the data file does not exist, an empty array will be returned. 98 | * 99 | * @return array the data rows to be inserted into the index. 100 | */ 101 | protected function getData() 102 | { 103 | if ($this->dataFile === null) { 104 | $class = new \ReflectionClass($this); 105 | $dataFile = dirname($class->getFileName()) . '/data/' . $this->getIndexSchema()->name . '.php'; 106 | return is_file($dataFile) ? require($dataFile) : []; 107 | } else { 108 | return parent::getData(); 109 | } 110 | } 111 | 112 | /** 113 | * Truncates the specified index removing all existing data from it. 114 | * This method is called before populating fixture data into the index associated with this fixture. 115 | */ 116 | protected function resetIndex() 117 | { 118 | $index = $this->getIndexSchema(); 119 | $this->db->createCommand()->truncateIndex($index->name)->execute(); 120 | } 121 | 122 | /** 123 | * @return IndexSchema the schema information of the database table associated with this fixture. 124 | * @throws \yii\base\InvalidConfigException if the index does not exist or not a runtime type 125 | */ 126 | public function getIndexSchema() 127 | { 128 | if ($this->_index !== null) { 129 | return $this->_index; 130 | } 131 | 132 | $db = $this->db; 133 | $indexName = $this->indexName; 134 | if ($indexName === null) { 135 | /* @var $modelClass ActiveRecord */ 136 | $modelClass = $this->modelClass; 137 | $indexName = $modelClass::indexName(); 138 | } 139 | 140 | $this->_index = $db->getSchema()->getIndexSchema($indexName); 141 | if ($this->_index === null) { 142 | throw new InvalidConfigException("Index does not exist: {$indexName}"); 143 | } 144 | if (!$this->_index->isRt) { 145 | throw new InvalidConfigException("'{$indexName}' is not a runtime index. Only runtime indexes are supported.'"); 146 | } 147 | 148 | return $this->_index; 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /src/ActiveQuery.php: -------------------------------------------------------------------------------- 1 | with('source')->asArray()->all(); 39 | * ``` 40 | * 41 | * ActiveQuery allows to build the snippets using sources provided by ActiveRecord. 42 | * You can use [[snippetByModel()]] method to enable this. 43 | * For example: 44 | * 45 | * ```php 46 | * class Article extends ActiveRecord 47 | * { 48 | * public function getSource() 49 | * { 50 | * return $this->hasOne('db', ArticleDb::className(), ['id' => 'id']); 51 | * } 52 | * 53 | * public function getSnippetSource() 54 | * { 55 | * return $this->source->content; 56 | * } 57 | * 58 | * ... 59 | * } 60 | * 61 | * $articles = Article::find()->with('source')->snippetByModel()->all(); 62 | * ``` 63 | * 64 | * Relational query 65 | * ---------------- 66 | * 67 | * In relational context ActiveQuery represents a relation between two Active Record classes. 68 | * 69 | * Relational ActiveQuery instances are usually created by calling [[ActiveRecord::hasOne()]] and 70 | * [[ActiveRecord::hasMany()]]. An Active Record class declares a relation by defining 71 | * a getter method which calls one of the above methods and returns the created ActiveQuery object. 72 | * 73 | * A relation is specified by [[link]] which represents the association between columns 74 | * of different tables; and the multiplicity of the relation is indicated by [[multiple]]. 75 | * 76 | * If a relation involves a junction table, it may be specified by [[via()]]. 77 | * This methods may only be called in a relational context. Same is true for [[inverseOf()]], which 78 | * marks a relation as inverse of another relation. 79 | * 80 | * @author Paul Klimov 81 | * @since 2.0 82 | */ 83 | class ActiveQuery extends Query implements ActiveQueryInterface 84 | { 85 | use ActiveQueryTrait; 86 | use ActiveRelationTrait; 87 | 88 | /** 89 | * @event Event an event that is triggered when the query is initialized via [[init()]]. 90 | */ 91 | const EVENT_INIT = 'init'; 92 | 93 | /** 94 | * @var string the SQL statement to be executed for retrieving AR records. 95 | * This is set by [[ActiveRecord::findBySql()]]. 96 | */ 97 | public $sql; 98 | 99 | 100 | /** 101 | * Constructor. 102 | * @param array $modelClass the model class associated with this query 103 | * @param array $config configurations to be applied to the newly created query object 104 | */ 105 | public function __construct($modelClass, $config = []) 106 | { 107 | $this->modelClass = $modelClass; 108 | parent::__construct($config); 109 | } 110 | 111 | /** 112 | * Initializes the object. 113 | * This method is called at the end of the constructor. The default implementation will trigger 114 | * an [[EVENT_INIT]] event. If you override this method, make sure you call the parent implementation at the end 115 | * to ensure triggering of the event. 116 | */ 117 | public function init() 118 | { 119 | parent::init(); 120 | $this->trigger(self::EVENT_INIT); 121 | } 122 | 123 | /** 124 | * Sets the [[snippetCallback]] to [[fetchSnippetSourceFromModels()]], which allows to 125 | * fetch the snippet source strings from the Active Record models, using method 126 | * [[ActiveRecord::getSnippetSource()]]. 127 | * For example: 128 | * 129 | * ```php 130 | * class Article extends ActiveRecord 131 | * { 132 | * public function getSnippetSource() 133 | * { 134 | * return file_get_contents('/path/to/source/files/' . $this->id . '.txt');; 135 | * } 136 | * } 137 | * 138 | * $articles = Article::find()->snippetByModel()->all(); 139 | * ``` 140 | * 141 | * Warning: this option should NOT be used with [[asArray]] at the same time! 142 | * @return $this the query object itself 143 | */ 144 | public function snippetByModel() 145 | { 146 | $this->snippetCallback([$this, 'fetchSnippetSourceFromModels']); 147 | 148 | return $this; 149 | } 150 | 151 | /** 152 | * Executes query and returns all results as an array. 153 | * @param Connection $db the DB connection used to create the DB command. 154 | * If null, the DB connection returned by [[modelClass]] will be used. 155 | * @return array|ActiveRecord[] the query results. If the query results in nothing, an empty array will be returned. 156 | */ 157 | public function all($db = null) 158 | { 159 | return parent::all($db); 160 | } 161 | 162 | /** 163 | * Executes query and returns a single row of result. 164 | * @param Connection $db the DB connection used to create the DB command. 165 | * If null, the DB connection returned by [[modelClass]] will be used. 166 | * @return ActiveRecord|array|null a single row of query result. Depending on the setting of [[asArray]], 167 | * the query result may be either an array or an ActiveRecord object. Null will be returned 168 | * if the query results in nothing. 169 | */ 170 | public function one($db = null) 171 | { 172 | $row = parent::one($db); 173 | if ($row !== false) { 174 | $models = $this->populate([$row]); 175 | return reset($models) ?: null; 176 | } else { 177 | return null; 178 | } 179 | } 180 | 181 | /** 182 | * {@inheritdoc} 183 | */ 184 | public function populate($rows) 185 | { 186 | if (empty($rows)) { 187 | return []; 188 | } 189 | 190 | $models = $this->createModels($rows); 191 | if (!empty($this->with)) { 192 | $this->findWith($this->with, $models); 193 | } 194 | $models = parent::populate($models); 195 | if (!$this->asArray) { 196 | foreach ($models as $model) { 197 | $model->afterFind(); 198 | } 199 | } 200 | 201 | return $models; 202 | } 203 | 204 | /** 205 | * Creates a DB command that can be used to execute this query. 206 | * @param Connection $db the DB connection used to create the DB command. 207 | * If null, the DB connection returned by [[modelClass]] will be used. 208 | * @return Command the created DB command instance. 209 | */ 210 | public function createCommand($db = null) 211 | { 212 | if ($this->primaryModel !== null) { 213 | // lazy loading a relational query 214 | if ($this->via instanceof self) { 215 | // via pivot index 216 | $viaModels = $this->via->findJunctionRows([$this->primaryModel]); 217 | $this->filterByModels($viaModels); 218 | } elseif (is_array($this->via)) { 219 | // via relation 220 | /* @var $viaQuery ActiveQuery */ 221 | list($viaName, $viaQuery) = $this->via; 222 | if ($viaQuery->multiple) { 223 | $viaModels = $viaQuery->all(); 224 | $this->primaryModel->populateRelation($viaName, $viaModels); 225 | } else { 226 | $model = $viaQuery->one(); 227 | $this->primaryModel->populateRelation($viaName, $model); 228 | $viaModels = $model === null ? [] : [$model]; 229 | } 230 | $this->filterByModels($viaModels); 231 | } else { 232 | $this->filterByModels([$this->primaryModel]); 233 | } 234 | } 235 | 236 | $this->setConnection($db); 237 | $db = $this->getConnection(); 238 | 239 | if ($this->sql === null) { 240 | list ($sql, $params) = $db->getQueryBuilder()->build($this); 241 | } else { 242 | $sql = $this->sql; 243 | $params = $this->params; 244 | } 245 | 246 | return $db->createCommand($sql, $params); 247 | } 248 | 249 | /** 250 | * {@inheritdoc} 251 | */ 252 | protected function defaultConnection() 253 | { 254 | $modelClass = $this->modelClass; 255 | 256 | return $modelClass::getDb(); 257 | } 258 | 259 | /** 260 | * Fetches the source for the snippets using [[ActiveRecord::getSnippetSource()]] method. 261 | * @param ActiveRecord[] $models raw query result rows. 262 | * @throws \yii\base\InvalidCallException if [[asArray]] enabled. 263 | * @return array snippet source strings 264 | */ 265 | protected function fetchSnippetSourceFromModels($models) 266 | { 267 | if ($this->asArray) { 268 | throw new InvalidCallException('"' . __METHOD__ . '" unable to determine snippet source from plain array. Either disable "asArray" option or use regular "snippetCallback"'); 269 | } 270 | $result = []; 271 | foreach ($models as $model) { 272 | $result[] = $model->getSnippetSource(); 273 | } 274 | 275 | return $result; 276 | } 277 | 278 | /** 279 | * {@inheritdoc} 280 | */ 281 | protected function callSnippets(array $source) 282 | { 283 | $from = $this->from; 284 | if ($from === null) { 285 | /* @var $modelClass ActiveRecord */ 286 | $modelClass = $this->modelClass; 287 | $tableName = $modelClass::indexName(); 288 | $from = [$tableName]; 289 | } 290 | 291 | return $this->callSnippetsInternal($source, $from[0]); 292 | } 293 | } 294 | -------------------------------------------------------------------------------- /src/ActiveRecord.php: -------------------------------------------------------------------------------- 1 | 28 | * @since 2.0 29 | */ 30 | abstract class ActiveRecord extends BaseActiveRecord 31 | { 32 | /** 33 | * The insert operation. This is mainly used when overriding [[transactions()]] to specify which operations are transactional. 34 | */ 35 | const OP_INSERT = 0x01; 36 | /** 37 | * The update operation. This is mainly used when overriding [[transactions()]] to specify which operations are transactional. 38 | */ 39 | const OP_UPDATE = 0x02; 40 | /** 41 | * The delete operation. This is mainly used when overriding [[transactions()]] to specify which operations are transactional. 42 | */ 43 | const OP_DELETE = 0x04; 44 | /** 45 | * All three operations: insert, update, delete. 46 | * This is a shortcut of the expression: OP_INSERT | OP_UPDATE | OP_DELETE. 47 | */ 48 | const OP_ALL = 0x07; 49 | 50 | /** 51 | * @var string current snippet value for this Active Record instance. 52 | * It will be filled up automatically when instance found using [[Query::snippetCallback]] 53 | * or [[ActiveQuery::snippetByModel()]]. 54 | */ 55 | private $_snippet; 56 | 57 | 58 | /** 59 | * Returns the Sphinx connection used by this AR class. 60 | * By default, the "sphinx" application component is used as the Sphinx connection. 61 | * You may override this method if you want to use a different Sphinx connection. 62 | * @return Connection the Sphinx connection used by this AR class. 63 | */ 64 | public static function getDb() 65 | { 66 | return \Yii::$app->get('sphinx'); 67 | } 68 | 69 | /** 70 | * Creates an [[ActiveQuery]] instance with a given SQL statement. 71 | * 72 | * Note that because the SQL statement is already specified, calling additional 73 | * query modification methods (such as `where()`, `order()`) on the created [[ActiveQuery]] 74 | * instance will have no effect. However, calling `with()`, `asArray()` or `indexBy()` is 75 | * still fine. 76 | * 77 | * Below is an example: 78 | * 79 | * ```php 80 | * $customers = Article::findBySql("SELECT * FROM `idx_article` WHERE MATCH('development')")->all(); 81 | * ``` 82 | * 83 | * @param string $sql the SQL statement to be executed 84 | * @param array $params parameters to be bound to the SQL statement during execution. 85 | * @return ActiveQuery the newly created [[ActiveQuery]] instance 86 | */ 87 | public static function findBySql($sql, $params = []) 88 | { 89 | $query = static::find(); 90 | $query->sql = $sql; 91 | 92 | return $query->params($params); 93 | } 94 | 95 | /** 96 | * Updates the whole table using the provided attribute values and conditions. 97 | * For example, to change the status to be 1 for all articles which status is 2: 98 | * 99 | * ```php 100 | * Article::updateAll(['status' => 1], 'status = 2'); 101 | * ``` 102 | * 103 | * @param array $attributes attribute values (name-value pairs) to be saved into the table 104 | * @param string|array $condition the conditions that will be put in the WHERE part of the UPDATE SQL. 105 | * Please refer to [[Query::where()]] on how to specify this parameter. 106 | * @param array $params the parameters (name => value) to be bound to the query. 107 | * @return int the number of rows updated 108 | */ 109 | public static function updateAll($attributes, $condition = '', $params = []) 110 | { 111 | $command = static::getDb()->createCommand(); 112 | $command->update(static::indexName(), $attributes, $condition, $params); 113 | 114 | return $command->execute(); 115 | } 116 | 117 | /** 118 | * Deletes rows in the index using the provided conditions. 119 | * 120 | * For example, to delete all articles whose status is 3: 121 | * 122 | * ```php 123 | * Article::deleteAll('status = 3'); 124 | * ``` 125 | * 126 | * @param string|array $condition the conditions that will be put in the WHERE part of the DELETE SQL. 127 | * Please refer to [[Query::where()]] on how to specify this parameter. 128 | * @param array $params the parameters (name => value) to be bound to the query. 129 | * @return int the number of rows deleted 130 | */ 131 | public static function deleteAll($condition = '', $params = []) 132 | { 133 | $command = static::getDb()->createCommand(); 134 | $command->delete(static::indexName(), $condition, $params); 135 | 136 | return $command->execute(); 137 | } 138 | 139 | /** 140 | * {@inheritdoc} 141 | * @return ActiveQuery the newly created [[ActiveQuery]] instance. 142 | */ 143 | public static function find() 144 | { 145 | return Yii::createObject(ActiveQuery::className(), [get_called_class()]); 146 | } 147 | 148 | /** 149 | * Declares the name of the Sphinx index associated with this AR class. 150 | * By default this method returns the class name as the index name by calling [[Inflector::camel2id()]]. 151 | * For example, 'Article' becomes 'article', and 'StockItem' becomes 152 | * 'stock_item'. You may override this method if the index is not named after this convention. 153 | * @return string the index name 154 | */ 155 | public static function indexName() 156 | { 157 | return Inflector::camel2id(StringHelper::basename(get_called_class()), '_'); 158 | } 159 | 160 | /** 161 | * Returns the schema information of the Sphinx index associated with this AR class. 162 | * @return IndexSchema the schema information of the Sphinx index associated with this AR class. 163 | * @throws InvalidConfigException if the index for the AR class does not exist. 164 | */ 165 | public static function getIndexSchema() 166 | { 167 | $schema = static::getDb()->getIndexSchema(static::indexName()); 168 | if ($schema !== null) { 169 | return $schema; 170 | } else { 171 | throw new InvalidConfigException("The index does not exist: " . static::indexName()); 172 | } 173 | } 174 | 175 | /** 176 | * Returns the primary key name for this AR class. 177 | * The default implementation will return the primary key as declared 178 | * in the Sphinx index, which is associated with this AR class. 179 | * 180 | * Note that an array should be returned even for a table with single primary key. 181 | * 182 | * @return string[] the primary keys of the associated Sphinx index. 183 | */ 184 | public static function primaryKey() 185 | { 186 | return [static::getIndexSchema()->primaryKey]; 187 | } 188 | 189 | /** 190 | * Builds a snippet from provided data and query, using specified index settings. 191 | * @param string|array $source is the source data to extract a snippet from. 192 | * It could be either a single string or array of strings. 193 | * @param string $match the full-text query to build snippets for. 194 | * @param array $options list of options in format: optionName => optionValue 195 | * @return string|array built snippet in case "source" is a string, list of built snippets 196 | * in case "source" is an array. 197 | */ 198 | public static function callSnippets($source, $match, $options = []) 199 | { 200 | $command = static::getDb()->createCommand(); 201 | $command->callSnippets(static::indexName(), $source, $match, $options); 202 | if (is_array($source)) { 203 | return $command->queryColumn(); 204 | } else { 205 | return $command->queryScalar(); 206 | } 207 | } 208 | 209 | /** 210 | * Returns tokenized and normalized forms of the keywords, and, optionally, keyword statistics. 211 | * @param string $text the text to break down to keywords. 212 | * @param bool $fetchStatistic whether to return document and hit occurrence statistics 213 | * @return array keywords and statistics 214 | */ 215 | public static function callKeywords($text, $fetchStatistic = false) 216 | { 217 | $command = static::getDb()->createCommand(); 218 | $command->callKeywords(static::indexName(), $text, $fetchStatistic); 219 | 220 | return $command->queryAll(); 221 | } 222 | 223 | /** 224 | * @param string $snippet 225 | */ 226 | public function setSnippet($snippet) 227 | { 228 | $this->_snippet = $snippet; 229 | } 230 | 231 | /** 232 | * Returns current snippet value or generates new one from given match. 233 | * @param string $match snippet source query 234 | * @param array $options list of options in format: optionName => optionValue 235 | * @return string snippet value 236 | */ 237 | public function getSnippet($match = null, $options = []) 238 | { 239 | if ($match !== null) { 240 | $this->_snippet = $this->fetchSnippet($match, $options); 241 | } 242 | 243 | return $this->_snippet; 244 | } 245 | 246 | /** 247 | * Builds up the snippet value from the given query. 248 | * @param string $match the full-text query to build snippets for. 249 | * @param array $options list of options in format: optionName => optionValue 250 | * @return string snippet value. 251 | */ 252 | protected function fetchSnippet($match, $options = []) 253 | { 254 | return static::callSnippets($this->getSnippetSource(), $match, $options); 255 | } 256 | 257 | /** 258 | * Returns the string, which should be used as a source to create snippet for this 259 | * Active Record instance. 260 | * Child classes must implement this method to return the actual snippet source text. 261 | * For example: 262 | * 263 | * ```php 264 | * public function getSnippetSource() 265 | * { 266 | * return $this->snippetSourceRelation->content; 267 | * } 268 | * ``` 269 | * 270 | * @return string snippet source string. 271 | * @throws \yii\base\NotSupportedException if this is not supported by the Active Record class 272 | */ 273 | public function getSnippetSource() 274 | { 275 | throw new NotSupportedException($this->className() . ' does not provide snippet source.'); 276 | } 277 | 278 | /** 279 | * Declares which operations should be performed within a transaction in different scenarios. 280 | * The supported DB operations are: [[OP_INSERT]], [[OP_UPDATE]] and [[OP_DELETE]], 281 | * which correspond to the [[insert()]], [[update()]] and [[delete()]] methods, respectively. 282 | * By default, these methods are NOT enclosed in a transaction. 283 | * 284 | * In some scenarios, to ensure data consistency, you may want to enclose some or all of them 285 | * in transactions. You can do so by overriding this method and returning the operations 286 | * that need to be transactional. For example, 287 | * 288 | * ```php 289 | * return [ 290 | * 'admin' => self::OP_INSERT, 291 | * 'api' => self::OP_INSERT | self::OP_UPDATE | self::OP_DELETE, 292 | * // the above is equivalent to the following: 293 | * // 'api' => self::OP_ALL, 294 | * 295 | * ]; 296 | * ``` 297 | * 298 | * The above declaration specifies that in the "admin" scenario, the insert operation ([[insert()]]) 299 | * should be done in a transaction; and in the "api" scenario, all the operations should be done 300 | * in a transaction. 301 | * 302 | * @return array the declarations of transactional operations. The array keys are scenarios names, 303 | * and the array values are the corresponding transaction operations. 304 | */ 305 | public function transactions() 306 | { 307 | return []; 308 | } 309 | 310 | /** 311 | * Returns the list of all attribute names of the model. 312 | * The default implementation will return all column names of the table associated with this AR class. 313 | * @return array list of attribute names. 314 | */ 315 | public function attributes() 316 | { 317 | return array_keys(static::getIndexSchema()->columns); 318 | } 319 | 320 | /** 321 | * Inserts a row into the associated Sphinx index using the attribute values of this record. 322 | * 323 | * This method performs the following steps in order: 324 | * 325 | * 1. call [[beforeValidate()]] when `$runValidation` is true. If validation 326 | * fails, it will skip the rest of the steps; 327 | * 2. call [[afterValidate()]] when `$runValidation` is true. 328 | * 3. call [[beforeSave()]]. If the method returns false, it will skip the 329 | * rest of the steps; 330 | * 4. insert the record into index. If this fails, it will skip the rest of the steps; 331 | * 5. call [[afterSave()]]; 332 | * 333 | * In the above step 1, 2, 3 and 5, events [[EVENT_BEFORE_VALIDATE]], 334 | * [[EVENT_BEFORE_INSERT]], [[EVENT_AFTER_INSERT]] and [[EVENT_AFTER_VALIDATE]] 335 | * will be raised by the corresponding methods. 336 | * 337 | * Only the [[changedAttributes|changed attribute values]] will be inserted. 338 | * 339 | * For example, to insert an article record: 340 | * 341 | * ```php 342 | * $article = new Article(); 343 | * $article->id = $id; 344 | * $article->genre_id = $genreId; 345 | * $article->content = $content; 346 | * $article->insert(); 347 | * ``` 348 | * 349 | * @param bool $runValidation whether to perform validation before saving the record. 350 | * If the validation fails, the record will not be inserted. 351 | * @param array $attributes list of attributes that need to be saved. Defaults to null, 352 | * meaning all attributes that are loaded from index will be saved. 353 | * @return bool whether the attributes are valid and the record is inserted successfully. 354 | * @throws \Exception in case insert failed. 355 | */ 356 | public function insert($runValidation = true, $attributes = null) 357 | { 358 | if ($runValidation && !$this->validate($attributes)) { 359 | return false; 360 | } 361 | $db = static::getDb(); 362 | if ($this->isTransactional(self::OP_INSERT) && $db->getTransaction() === null) { 363 | $transaction = $db->beginTransaction(); 364 | try { 365 | $result = $this->insertInternal($attributes); 366 | if ($result === false) { 367 | $transaction->rollBack(); 368 | } else { 369 | $transaction->commit(); 370 | } 371 | } catch (\Exception $e) { 372 | $transaction->rollBack(); 373 | throw $e; 374 | } 375 | } else { 376 | $result = $this->insertInternal($attributes); 377 | } 378 | 379 | return $result; 380 | } 381 | 382 | /** 383 | * @see ActiveRecord::insert() 384 | */ 385 | private function insertInternal($attributes = null) 386 | { 387 | if (!$this->beforeSave(true)) { 388 | return false; 389 | } 390 | $values = $this->getDirtyAttributes($attributes); 391 | if (empty($values)) { 392 | foreach ($this->getPrimaryKey(true) as $key => $value) { 393 | $values[$key] = $value; 394 | } 395 | } 396 | $db = static::getDb(); 397 | $command = $db->createCommand()->insert($this->indexName(), $values); 398 | if (!$command->execute()) { 399 | return false; 400 | } 401 | 402 | $changedAttributes = array_fill_keys(array_keys($values), null); 403 | $this->setOldAttributes($values); 404 | $this->afterSave(true, $changedAttributes); 405 | 406 | return true; 407 | } 408 | 409 | /** 410 | * Saves the changes to this active record into the associated Sphinx index. 411 | * 412 | * This method performs the following steps in order: 413 | * 414 | * 1. call [[beforeValidate()]] when `$runValidation` is true. If validation 415 | * fails, it will skip the rest of the steps; 416 | * 2. call [[afterValidate()]] when `$runValidation` is true. 417 | * 3. call [[beforeSave()]]. If the method returns false, it will skip the 418 | * rest of the steps; 419 | * 4. save the record into index. If this fails, it will skip the rest of the steps; 420 | * 5. call [[afterSave()]]; 421 | * 422 | * In the above step 1, 2, 3 and 5, events [[EVENT_BEFORE_VALIDATE]], 423 | * [[EVENT_BEFORE_UPDATE]], [[EVENT_AFTER_UPDATE]] and [[EVENT_AFTER_VALIDATE]] 424 | * will be raised by the corresponding methods. 425 | * 426 | * Only the [[changedAttributes|changed attribute values]] will be saved into database. 427 | * 428 | * For example, to update an article record: 429 | * 430 | * ```php 431 | * $article = Article::findOne($id); 432 | * $article->genre_id = $genreId; 433 | * $article->group_id = $groupId; 434 | * $article->update(); 435 | * ``` 436 | * 437 | * Note that it is possible the update does not affect any row in the table. 438 | * In this case, this method will return 0. For this reason, you should use the following 439 | * code to check if update() is successful or not: 440 | * 441 | * ```php 442 | * if ($this->update() !== false) { 443 | * // update successful 444 | * } else { 445 | * // update failed 446 | * } 447 | * ``` 448 | * 449 | * @param bool $runValidation whether to perform validation before saving the record. 450 | * If the validation fails, the record will not be inserted into the database. 451 | * @param array $attributeNames list of attributes that need to be saved. Defaults to null, 452 | * meaning all attributes that are loaded from DB will be saved. 453 | * @return int|false the number of rows affected, or `false` if validation fails 454 | * or [[beforeSave()]] stops the updating process. 455 | * @throws StaleObjectException if [[optimisticLock|optimistic locking]] is enabled and the data 456 | * being updated is outdated. 457 | * @throws \Exception in case update failed. 458 | */ 459 | public function update($runValidation = true, $attributeNames = null) 460 | { 461 | if ($runValidation && !$this->validate($attributeNames)) { 462 | return false; 463 | } 464 | $db = static::getDb(); 465 | if ($this->isTransactional(self::OP_UPDATE) && $db->getTransaction() === null) { 466 | $transaction = $db->beginTransaction(); 467 | try { 468 | $result = $this->updateInternal($attributeNames); 469 | if ($result === false) { 470 | $transaction->rollBack(); 471 | } else { 472 | $transaction->commit(); 473 | } 474 | } catch (\Exception $e) { 475 | $transaction->rollBack(); 476 | throw $e; 477 | } 478 | } else { 479 | $result = $this->updateInternal($attributeNames); 480 | } 481 | 482 | return $result; 483 | } 484 | 485 | /** 486 | * @see CActiveRecord::update() 487 | * @throws StaleObjectException 488 | */ 489 | protected function updateInternal($attributes = null) 490 | { 491 | if (!$this->beforeSave(false)) { 492 | return false; 493 | } 494 | $values = $this->getDirtyAttributes($attributes); 495 | if (empty($values)) { 496 | $this->afterSave(false, $values); 497 | return 0; 498 | } 499 | 500 | // Replace is supported only by runtime indexes and necessary only for field update 501 | $useReplace = false; 502 | $indexSchema = $this->getIndexSchema(); 503 | if ($this->getIndexSchema()->isRt) { 504 | foreach ($values as $name => $value) { 505 | $columnSchema = $indexSchema->getColumn($name); 506 | if ($columnSchema->isField) { 507 | $useReplace = true; 508 | break; 509 | } 510 | } 511 | } 512 | 513 | if ($useReplace) { 514 | $values = array_merge( 515 | $this->getAttributes(), 516 | $values, 517 | $this->getOldPrimaryKey(true) 518 | ); 519 | $command = static::getDb()->createCommand(); 520 | $command->replace(static::indexName(), $values); 521 | // We do not check the return value of replace because it's possible 522 | // that the REPLACE statement doesn't change anything and thus returns 0. 523 | $rows = $command->execute(); 524 | } else { 525 | $condition = $this->getOldPrimaryKey(true); 526 | $lock = $this->optimisticLock(); 527 | if ($lock !== null) { 528 | if (!isset($values[$lock])) { 529 | $values[$lock] = $this->$lock + 1; 530 | } 531 | $condition[$lock] = $this->$lock; 532 | } 533 | // We do not check the return value of updateAll() because it's possible 534 | // that the UPDATE statement doesn't change anything and thus returns 0. 535 | $rows = $this->updateAll($values, $condition); 536 | 537 | if ($lock !== null && !$rows) { 538 | throw new StaleObjectException('The object being updated is outdated.'); 539 | } 540 | 541 | if (isset($values[$lock])) { 542 | $this->$lock = $values[$lock]; 543 | } 544 | } 545 | 546 | $changedAttributes = []; 547 | foreach ($values as $name => $value) { 548 | $changedAttributes[$name] = $this->getOldAttribute($name); 549 | $this->setOldAttribute($name, $value); 550 | } 551 | $this->afterSave(false, $changedAttributes); 552 | 553 | return $rows; 554 | } 555 | 556 | /** 557 | * Deletes the index entry corresponding to this active record. 558 | * 559 | * This method performs the following steps in order: 560 | * 561 | * 1. call [[beforeDelete()]]. If the method returns false, it will skip the 562 | * rest of the steps; 563 | * 2. delete the record from the index; 564 | * 3. call [[afterDelete()]]. 565 | * 566 | * In the above step 1 and 3, events named [[EVENT_BEFORE_DELETE]] and [[EVENT_AFTER_DELETE]] 567 | * will be raised by the corresponding methods. 568 | * 569 | * @return int|false the number of rows deleted, or `false` if the deletion is unsuccessful for some reason. 570 | * Note that it is possible the number of rows deleted is 0, even though the deletion execution is successful. 571 | * @throws StaleObjectException if [[optimisticLock|optimistic locking]] is enabled and the data 572 | * being deleted is outdated. 573 | * @throws \Exception in case delete failed. 574 | */ 575 | public function delete() 576 | { 577 | $db = static::getDb(); 578 | $transaction = $this->isTransactional(self::OP_DELETE) && $db->getTransaction() === null ? $db->beginTransaction() : null; 579 | try { 580 | $result = false; 581 | if ($this->beforeDelete()) { 582 | // we do not check the return value of deleteAll() because it's possible 583 | // the record is already deleted in the database and thus the method will return 0 584 | $condition = $this->getOldPrimaryKey(true); 585 | $lock = $this->optimisticLock(); 586 | if ($lock !== null) { 587 | $condition[$lock] = $this->$lock; 588 | } 589 | $result = $this->deleteAll($condition); 590 | if ($lock !== null && !$result) { 591 | throw new StaleObjectException('The object being deleted is outdated.'); 592 | } 593 | $this->setOldAttributes(null); 594 | $this->afterDelete(); 595 | } 596 | if ($transaction !== null) { 597 | if ($result === false) { 598 | $transaction->rollBack(); 599 | } else { 600 | $transaction->commit(); 601 | } 602 | } 603 | } catch (\Exception $e) { 604 | if ($transaction !== null) { 605 | $transaction->rollBack(); 606 | } 607 | throw $e; 608 | } 609 | 610 | return $result; 611 | } 612 | 613 | /** 614 | * Returns a value indicating whether the given active record is the same as the current one. 615 | * The comparison is made by comparing the index names and the primary key values of the two active records. 616 | * If one of the records [[isNewRecord|is new]] they are also considered not equal. 617 | * @param ActiveRecord $record record to compare to 618 | * @return bool whether the two active records refer to the same row in the same index. 619 | */ 620 | public function equals($record) 621 | { 622 | if ($this->isNewRecord || $record->isNewRecord) { 623 | return false; 624 | } 625 | 626 | return $this->indexName() === $record->indexName() && $this->getPrimaryKey() === $record->getPrimaryKey(); 627 | } 628 | 629 | /** 630 | * {@inheritdoc} 631 | */ 632 | public static function populateRecord($record, $row) 633 | { 634 | $columns = static::getIndexSchema()->columns; 635 | foreach ($row as $name => $value) { 636 | if (isset($columns[$name])) { 637 | if ($columns[$name]->isMva) { 638 | if (empty($value)) { 639 | $row[$name] = []; 640 | } else { 641 | $mvaValue = explode(',', $value); 642 | $row[$name] = array_map([$columns[$name], 'phpTypecast'], $mvaValue); 643 | } 644 | } else { 645 | $row[$name] = $columns[$name]->phpTypecast($value); 646 | } 647 | } 648 | } 649 | parent::populateRecord($record, $row); 650 | } 651 | 652 | /** 653 | * Returns a value indicating whether the specified operation is transactional in the current [[scenario]]. 654 | * @param int $operation the operation to check. Possible values are [[OP_INSERT]], [[OP_UPDATE]] and [[OP_DELETE]]. 655 | * @return bool whether the specified operation is transactional in the current [[scenario]]. 656 | */ 657 | public function isTransactional($operation) 658 | { 659 | $scenario = $this->getScenario(); 660 | $transactions = $this->transactions(); 661 | 662 | return isset($transactions[$scenario]) && ($transactions[$scenario] & $operation); 663 | } 664 | } 665 | -------------------------------------------------------------------------------- /src/ColumnSchema.php: -------------------------------------------------------------------------------- 1 | 17 | * @since 2.0 18 | */ 19 | class ColumnSchema extends BaseObject 20 | { 21 | /** 22 | * @var string name of this column (without quotes). 23 | */ 24 | public $name; 25 | /** 26 | * @var string abstract type of this column. Possible abstract types include: 27 | * string, text, boolean, smallint, integer, bigint, float, decimal, datetime, 28 | * timestamp, time, date, binary, and money. 29 | */ 30 | public $type; 31 | /** 32 | * @var string the PHP type of this column. Possible PHP types include: 33 | * string, boolean, integer, double. 34 | */ 35 | public $phpType; 36 | /** 37 | * @var string the DB type of this column. Possible DB types vary according to the type of DBMS. 38 | */ 39 | public $dbType; 40 | /** 41 | * @var bool whether this column is a primary key 42 | */ 43 | public $isPrimaryKey; 44 | /** 45 | * @var bool whether this column is an attribute 46 | */ 47 | public $isAttribute; 48 | /** 49 | * @var bool whether this column is a indexed field 50 | */ 51 | public $isField; 52 | /** 53 | * @var bool whether this column is a multi value attribute (MVA) 54 | */ 55 | public $isMva; 56 | 57 | 58 | /** 59 | * Converts the input value according to [[phpType]] after retrieval from the database. 60 | * If the value is null or an [[Expression]], it will not be converted. 61 | * @param mixed $value input value 62 | * @return mixed converted value 63 | */ 64 | public function phpTypecast($value) 65 | { 66 | return $this->typecast($value); 67 | } 68 | 69 | /** 70 | * Converts the input value according to [[type]] and [[dbType]] for use in a db query. 71 | * If the value is null or an [[Expression]], it will not be converted. 72 | * @param mixed $value input value 73 | * @return mixed converted value. This may also be an array containing the value as the first element 74 | * and the PDO type as the second element. 75 | */ 76 | public function dbTypecast($value) 77 | { 78 | // the default implementation does the same as casting for PHP, but it should be possible 79 | // to override this with annotation of explicit PDO type. 80 | return $this->typecast($value); 81 | } 82 | 83 | /** 84 | * Converts the input value according to [[phpType]] after retrieval from the database. 85 | * If the value is null or an [[Expression]], it will not be converted. 86 | * @param mixed $value input value 87 | * @return mixed converted value 88 | * @since 2.0.3 89 | */ 90 | protected function typecast($value) 91 | { 92 | if ($value === '' && $this->type !== Schema::TYPE_STRING) { 93 | return null; 94 | } 95 | if ($value === null || gettype($value) === $this->phpType || $value instanceof Expression) { 96 | return $value; 97 | } 98 | switch ($this->phpType) { 99 | case 'resource': 100 | case 'string': 101 | return is_resource($value) ? $value : (string) $value; 102 | case 'int': 103 | case 'integer': 104 | return (int) $value; 105 | case 'bool': 106 | case 'boolean': 107 | return (bool) $value; 108 | case 'double': 109 | return (double) $value; 110 | } 111 | 112 | return $value; 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/Command.php: -------------------------------------------------------------------------------- 1 | createCommand("SELECT * FROM `idx_article` WHERE MATCH('programming')")->queryAll(); 27 | * ``` 28 | * 29 | * Command supports SQL statement preparation and parameter binding just as [[\yii\db\Command]] does. 30 | * 31 | * Command also supports building SQL statements by providing methods such as [[insert()]], 32 | * [[update()]], etc. For example, 33 | * 34 | * ```php 35 | * $connection->createCommand()->update('idx_article', [ 36 | * 'genre_id' => 15, 37 | * 'author_id' => 157, 38 | * ])->execute(); 39 | * ``` 40 | * 41 | * To build SELECT SQL statements, please use [[Query]] and [[QueryBuilder]] instead. 42 | * 43 | * @author Paul Klimov 44 | * @since 2.0 45 | */ 46 | class Command extends \yii\db\Command 47 | { 48 | /** 49 | * @var \yii\sphinx\Connection the Sphinx connection that this command is associated with. 50 | */ 51 | public $db; 52 | 53 | 54 | /** 55 | * Creates a batch INSERT command. 56 | * For example, 57 | * 58 | * ```php 59 | * $connection->createCommand()->batchInsert('idx_user', ['name', 'age'], [ 60 | * ['Tom', 30], 61 | * ['Jane', 20], 62 | * ['Linda', 25], 63 | * ])->execute(); 64 | * ``` 65 | * 66 | * Note that the values in each row must match the corresponding column names. 67 | * 68 | * @param string $index the index that new rows will be inserted into. 69 | * @param array $columns the column names 70 | * @param array $rows the rows to be batch inserted into the index 71 | * @return $this the command object itself 72 | */ 73 | public function batchInsert($index, $columns, $rows) 74 | { 75 | $params = []; 76 | $sql = $this->db->getQueryBuilder()->batchInsert($index, $columns, $rows, $params); 77 | 78 | return $this->setSql($sql)->bindValues($params); 79 | } 80 | 81 | /** 82 | * Creates an REPLACE command. 83 | * For example, 84 | * 85 | * ```php 86 | * $connection->createCommand()->replace('idx_user', [ 87 | * 'name' => 'Sam', 88 | * 'age' => 30, 89 | * ])->execute(); 90 | * ``` 91 | * 92 | * The method will properly escape the column names, and bind the values to be replaced. 93 | * 94 | * Note that the created command is not executed until [[execute()]] is called. 95 | * 96 | * @param string $index the index that new rows will be replaced into. 97 | * @param array $columns the column data (name => value) to be replaced into the index. 98 | * @return $this the command object itself 99 | */ 100 | public function replace($index, $columns) 101 | { 102 | $params = []; 103 | $sql = $this->db->getQueryBuilder()->replace($index, $columns, $params); 104 | 105 | return $this->setSql($sql)->bindValues($params); 106 | } 107 | 108 | /** 109 | * Creates a batch REPLACE command. 110 | * For example, 111 | * 112 | * ```php 113 | * $connection->createCommand()->batchReplace('idx_user', ['name', 'age'], [ 114 | * ['Tom', 30], 115 | * ['Jane', 20], 116 | * ['Linda', 25], 117 | * ])->execute(); 118 | * ``` 119 | * 120 | * Note that the values in each row must match the corresponding column names. 121 | * 122 | * @param string $index the index that new rows will be replaced. 123 | * @param array $columns the column names 124 | * @param array $rows the rows to be batch replaced in the index 125 | * @return $this the command object itself 126 | */ 127 | public function batchReplace($index, $columns, $rows) 128 | { 129 | $params = []; 130 | $sql = $this->db->getQueryBuilder()->batchReplace($index, $columns, $rows, $params); 131 | 132 | return $this->setSql($sql)->bindValues($params); 133 | } 134 | 135 | /** 136 | * Creates an UPDATE command. 137 | * For example, 138 | * 139 | * ```php 140 | * $connection->createCommand()->update('user', ['status' => 1], 'age > 30')->execute(); 141 | * ``` 142 | * 143 | * The method will properly escape the column names and bind the values to be updated. 144 | * 145 | * Note that the created command is not executed until [[execute()]] is called. 146 | * 147 | * @param string $index the index to be updated. 148 | * @param array $columns the column data (name => value) to be updated. 149 | * @param string|array $condition the condition that will be put in the WHERE part. Please 150 | * refer to [[Query::where()]] on how to specify condition. 151 | * @param array $params the parameters to be bound to the command 152 | * @param array $options list of options in format: optionName => optionValue 153 | * @return $this the command object itself 154 | */ 155 | public function update($index, $columns, $condition = '', $params = [], $options = []) 156 | { 157 | $sql = $this->db->getQueryBuilder()->update($index, $columns, $condition, $params, $options); 158 | 159 | return $this->setSql($sql)->bindValues($params); 160 | } 161 | 162 | /** 163 | * Creates a SQL command for truncating a runtime index. 164 | * @param string $index the index to be truncated. The name will be properly quoted by the method. 165 | * @return $this the command object itself 166 | */ 167 | public function truncateIndex($index) 168 | { 169 | $sql = $this->db->getQueryBuilder()->truncateIndex($index); 170 | 171 | return $this->setSql($sql); 172 | } 173 | 174 | /** 175 | * Builds a snippet from provided data and query, using specified index settings. 176 | * @param string $index name of the index, from which to take the text processing settings. 177 | * @param string|array $source is the source data to extract a snippet from. 178 | * It could be either a single string or array of strings. 179 | * @param string $match the full-text query to build snippets for. 180 | * @param array $options list of options in format: optionName => optionValue 181 | * @return $this the command object itself 182 | */ 183 | public function callSnippets($index, $source, $match, $options = []) 184 | { 185 | $params = []; 186 | $sql = $this->db->getQueryBuilder()->callSnippets($index, $source, $match, $options, $params); 187 | 188 | return $this->setSql($sql)->bindValues($params); 189 | } 190 | 191 | /** 192 | * Returns tokenized and normalized forms of the keywords, and, optionally, keyword statistics. 193 | * @param string $index the name of the index from which to take the text processing settings 194 | * @param string $text the text to break down to keywords. 195 | * @param bool $fetchStatistic whether to return document and hit occurrence statistics 196 | * @return $this the command object itself 197 | */ 198 | public function callKeywords($index, $text, $fetchStatistic = false) 199 | { 200 | $params = []; 201 | $sql = $this->db->getQueryBuilder()->callKeywords($index, $text, $fetchStatistic, $params); 202 | 203 | return $this->setSql($sql)->bindValues($params); 204 | } 205 | 206 | // Float handling : 207 | 208 | /** 209 | * @var array list of 'float' type params, which should be inserted into SQL directly instead of binding. 210 | * @see Connection::enableFloatConversion 211 | * @since 2.0.6 212 | */ 213 | private $floatParams = []; 214 | 215 | /** 216 | * {@inheritdoc} 217 | */ 218 | public function bindValue($name, $value, $dataType = null) 219 | { 220 | if ($this->db->enableFloatConversion && $dataType === null && is_float($value)) { 221 | $this->floatParams[$name] = $value; 222 | return $this; 223 | } 224 | return parent::bindValue($name, $value, $dataType); 225 | } 226 | 227 | /** 228 | * {@inheritdoc} 229 | */ 230 | public function bindValues($values) 231 | { 232 | if ($this->db->enableFloatConversion) { 233 | if (empty($values)) { 234 | return $this; 235 | } 236 | 237 | foreach ($values as $name => $value) { 238 | if (is_float($value)) { 239 | $this->floatParams[$name] = $value; 240 | unset($values[$name]); 241 | } 242 | } 243 | } 244 | 245 | return parent::bindValues($values); 246 | } 247 | 248 | /** 249 | * {@inheritdoc} 250 | */ 251 | public function prepare($forRead = null) 252 | { 253 | if ($this->pdoStatement && empty($this->floatParams)) { 254 | $this->bindPendingParams(); 255 | return; 256 | } 257 | 258 | $sql = $this->getSql(); 259 | 260 | if ($this->db->getTransaction()) { 261 | // master is in a transaction. use the same connection. 262 | $forRead = false; 263 | } 264 | if ($forRead || $forRead === null && $this->db->getSchema()->isReadQuery($sql)) { 265 | $pdo = $this->db->getSlavePdo(); 266 | } else { 267 | $pdo = $this->db->getMasterPdo(); 268 | } 269 | 270 | if (!empty($this->floatParams)) { 271 | $sql = $this->parseFloatParams($sql); 272 | } 273 | 274 | try { 275 | $this->pdoStatement = $pdo->prepare($sql); 276 | $this->bindPendingParams(); 277 | } catch (\Exception $e) { 278 | $message = $e->getMessage() . "\nFailed to prepare SphinxQL: $sql"; 279 | $errorInfo = $e instanceof \PDOException ? $e->errorInfo : null; 280 | throw new Exception($message, $errorInfo, (int) $e->getCode(), $e); 281 | } 282 | } 283 | 284 | /** 285 | * Parses given SQL replacing bound [[floatParams]] with their values. 286 | * @param string $sql source SQL. 287 | * @return string adjusted SQL. 288 | */ 289 | private function parseFloatParams($sql) 290 | { 291 | foreach ($this->floatParams as $name => $value) { 292 | if (strncmp($name, ':', 1) !== 0) { 293 | $name = ':' . $name; 294 | } 295 | // unable to use `str_replace()` because particular param name may be a substring of another param name 296 | $sql = preg_replace('/' . preg_quote($name) . '\b/s', $value, $sql); 297 | }; 298 | 299 | return $sql; 300 | } 301 | 302 | /** 303 | * {@inheritdoc} 304 | */ 305 | public function getRawSql() 306 | { 307 | return $this->parseFloatParams(parent::getRawSql()); 308 | } 309 | 310 | // Not Supported : 311 | 312 | /** 313 | * {@inheritdoc} 314 | */ 315 | public function createTable($table, $columns, $options = null) 316 | { 317 | $sql = $this->db->getQueryBuilder()->createTable($table, $columns, $options); 318 | 319 | return $this->setSql($sql); 320 | } 321 | 322 | /** 323 | * {@inheritdoc} 324 | */ 325 | public function renameTable($table, $newName) 326 | { 327 | throw new NotSupportedException('"' . __METHOD__ . '" is not supported.'); 328 | } 329 | 330 | /** 331 | * {@inheritdoc} 332 | */ 333 | public function dropTable($table) 334 | { 335 | $sql = $this->db->getQueryBuilder()->dropTable($table); 336 | 337 | return $this->setSql($sql); 338 | } 339 | 340 | /** 341 | * {@inheritdoc} 342 | */ 343 | public function truncateTable($table) 344 | { 345 | $sql = $this->db->getQueryBuilder()->truncateIndex($table); 346 | 347 | return $this->setSql($sql); 348 | } 349 | 350 | /** 351 | * {@inheritdoc} 352 | */ 353 | public function addColumn($table, $column, $type) 354 | { 355 | $sql = $this->db->getQueryBuilder()->addColumn($table, $column, $type); 356 | 357 | return $this->setSql($sql);; 358 | } 359 | 360 | /** 361 | * {@inheritdoc} 362 | */ 363 | public function dropColumn($table, $column) 364 | { 365 | $sql = $this->db->getQueryBuilder()->dropColumn($table, $column); 366 | 367 | return $this->setSql($sql); 368 | } 369 | 370 | /** 371 | * {@inheritdoc} 372 | */ 373 | public function renameColumn($table, $oldName, $newName) 374 | { 375 | throw new NotSupportedException('"' . __METHOD__ . '" is not supported.'); 376 | } 377 | 378 | /** 379 | * {@inheritdoc} 380 | */ 381 | public function alterColumn($table, $column, $type) 382 | { 383 | throw new NotSupportedException('"' . __METHOD__ . '" is not supported.'); 384 | } 385 | 386 | /** 387 | * {@inheritdoc} 388 | */ 389 | public function addPrimaryKey($name, $table, $columns) 390 | { 391 | throw new NotSupportedException('"' . __METHOD__ . '" is not supported.'); 392 | } 393 | 394 | /** 395 | * {@inheritdoc} 396 | */ 397 | public function dropPrimaryKey($name, $table) 398 | { 399 | throw new NotSupportedException('"' . __METHOD__ . '" is not supported.'); 400 | } 401 | 402 | /** 403 | * {@inheritdoc} 404 | */ 405 | public function addForeignKey($name, $table, $columns, $refTable, $refColumns, $delete = null, $update = null) 406 | { 407 | throw new NotSupportedException('"' . __METHOD__ . '" is not supported.'); 408 | } 409 | 410 | /** 411 | * {@inheritdoc} 412 | */ 413 | public function dropForeignKey($name, $table) 414 | { 415 | throw new NotSupportedException('"' . __METHOD__ . '" is not supported.'); 416 | } 417 | 418 | /** 419 | * {@inheritdoc} 420 | */ 421 | public function createIndex($name, $table, $columns, $unique = false) 422 | { 423 | throw new NotSupportedException('"' . __METHOD__ . '" is not supported.'); 424 | } 425 | 426 | /** 427 | * {@inheritdoc} 428 | */ 429 | public function dropIndex($name, $table) 430 | { 431 | throw new NotSupportedException('"' . __METHOD__ . '" is not supported.'); 432 | } 433 | 434 | /** 435 | * {@inheritdoc} 436 | */ 437 | public function resetSequence($table, $value = null) 438 | { 439 | throw new NotSupportedException('"' . __METHOD__ . '" is not supported.'); 440 | } 441 | 442 | /** 443 | * {@inheritdoc} 444 | */ 445 | public function checkIntegrity($check = true, $schema = '', $table = '') 446 | { 447 | throw new NotSupportedException('"' . __METHOD__ . '" is not supported.'); 448 | } 449 | } 450 | -------------------------------------------------------------------------------- /src/Connection.php: -------------------------------------------------------------------------------- 1 | 'mysql:host=127.0.0.1;port=9306;', 33 | * 'username' => $username, 34 | * 'password' => $password, 35 | * ]); 36 | * $connection->open(); 37 | * ``` 38 | * 39 | * After the Sphinx connection is established, one can execute SQL statements like the following: 40 | * 41 | * ```php 42 | * $command = $connection->createCommand("SELECT * FROM idx_article WHERE MATCH('programming')"); 43 | * $articles = $command->queryAll(); 44 | * $command = $connection->createCommand('UPDATE idx_article SET status=2 WHERE id=1'); 45 | * $command->execute(); 46 | * ``` 47 | * 48 | * For more information about how to perform various DB queries, please refer to [[Command]]. 49 | * 50 | * This class supports transactions exactly as "yii\db\Connection". 51 | * 52 | * Note: while this class extends "yii\db\Connection" some of its methods are not supported. 53 | * 54 | * @method \yii\sphinx\Schema getSchema() The schema information for this Sphinx connection 55 | * @method \yii\sphinx\QueryBuilder getQueryBuilder() the query builder for this Sphinx connection 56 | * 57 | * @property-read string $lastInsertID The row ID of the last row inserted, or the last value retrieved from 58 | * the sequence object. 59 | * 60 | * @author Paul Klimov 61 | * @since 2.0 62 | */ 63 | class Connection extends \yii\db\Connection 64 | { 65 | /** 66 | * {@inheritdoc} 67 | */ 68 | public $schemaMap = [ 69 | 'mysqli' => 'yii\sphinx\Schema', // MySQL 70 | 'mysql' => 'yii\sphinx\Schema', // MySQL 71 | ]; 72 | /** 73 | * @var bool whether to enable conversion of the float query params into the direct literal SQL insertion. 74 | * This allows processing of the float values, since PDO does not provide specific param type for float binding, 75 | * while Sphinx is unable to process float values passed as quoted strings. 76 | * @since 2.0.6 77 | */ 78 | public $enableFloatConversion = true; 79 | 80 | 81 | /** 82 | * Obtains the schema information for the named index. 83 | * @param string $name index name. 84 | * @param bool $refresh whether to reload the table schema even if it is found in the cache. 85 | * @return IndexSchema index schema information. Null if the named index does not exist. 86 | */ 87 | public function getIndexSchema($name, $refresh = false) 88 | { 89 | return $this->getSchema()->getIndexSchema($name, $refresh); 90 | } 91 | 92 | /** 93 | * Quotes a index name for use in a query. 94 | * If the index name contains schema prefix, the prefix will also be properly quoted. 95 | * If the index name is already quoted or contains special characters including '(', '[[' and '{{', 96 | * then this method will do nothing. 97 | * @param string $name index name 98 | * @return string the properly quoted index name 99 | */ 100 | public function quoteIndexName($name) 101 | { 102 | return $this->getSchema()->quoteIndexName($name); 103 | } 104 | 105 | /** 106 | * Alias of [[quoteIndexName()]]. 107 | * @param string $name table name 108 | * @return string the properly quoted table name 109 | */ 110 | public function quoteTableName($name) 111 | { 112 | return $this->quoteIndexName($name); 113 | } 114 | 115 | /** 116 | * Creates a command for execution. 117 | * @param string $sql the SQL statement to be executed 118 | * @param array $params the parameters to be bound to the SQL statement 119 | * @return Command the Sphinx command 120 | */ 121 | public function createCommand($sql = null, $params = []) 122 | { 123 | $command = new Command([ 124 | 'db' => $this, 125 | 'sql' => $sql, 126 | ]); 127 | 128 | return $command->bindValues($params); 129 | } 130 | 131 | /** 132 | * This method is not supported by Sphinx. 133 | * @param string $sequenceName name of the sequence object 134 | * @return string the row ID of the last row inserted, or the last value retrieved from the sequence object 135 | * @throws \yii\base\NotSupportedException always. 136 | */ 137 | public function getLastInsertID($sequenceName = '') 138 | { 139 | throw new NotSupportedException('"' . __METHOD__ . '" is not supported.'); 140 | } 141 | 142 | /** 143 | * Escapes all special characters from 'MATCH' statement argument. 144 | * Make sure you are using this method whenever composing 'MATCH' search statement. 145 | * Note: this method does not perform quoting, you should place the result in the quotes 146 | * an perform additional escaping for it manually, the best way to do it is using PDO parameter. 147 | * @param string $str string to be escaped. 148 | * @return string the properly escaped string. 149 | */ 150 | public function escapeMatchValue($str) 151 | { 152 | return str_replace( 153 | ['\\', '/', '"', '(', ')', '|', '-', '!', '@', '~', '&', '^', '$', '=', '>', '<', "\x00", "\n", "\r", "\x1a"], 154 | ['\\\\', '\\/', '\\"', '\\(', '\\)', '\\|', '\\-', '\\!', '\\@', '\\~', '\\&', '\\^', '\\$', '\\=', '\\>', '\\<', "\\x00", "\\n", "\\r", "\\x1a"], 155 | $str 156 | ); 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /src/IndexSchema.php: -------------------------------------------------------------------------------- 1 | 19 | * @since 2.0 20 | */ 21 | class IndexSchema extends BaseObject 22 | { 23 | /** 24 | * @var string name of this index. 25 | */ 26 | public $name; 27 | /** 28 | * @var string type of the index. 29 | */ 30 | public $type; 31 | /** 32 | * @var bool whether this index is a real-time index. 33 | */ 34 | public $isRt; 35 | /** 36 | * @var string primary key of this index. 37 | */ 38 | public $primaryKey; 39 | /** 40 | * @var ColumnSchema[] column metadata of this index. Each array element is a [[ColumnSchema]] object, indexed by column names. 41 | */ 42 | public $columns = []; 43 | 44 | 45 | /** 46 | * Gets the named column metadata. 47 | * This is a convenient method for retrieving a named column even if it does not exist. 48 | * @param string $name column name 49 | * @return ColumnSchema metadata of the named column. Null if the named column does not exist. 50 | */ 51 | public function getColumn($name) 52 | { 53 | return isset($this->columns[$name]) ? $this->columns[$name] : null; 54 | } 55 | 56 | /** 57 | * Returns the names of all columns in this table. 58 | * @return array list of column names 59 | */ 60 | public function getColumnNames() 61 | { 62 | return array_keys($this->columns); 63 | } 64 | 65 | /** 66 | * @deprecated 67 | * This method is deprecated, use [[isRt]] instead. 68 | * @return bool whether this index is a real-time index. 69 | * @since 2.0.9 70 | */ 71 | public function isIsRuntime() 72 | { 73 | return $this->isRt; 74 | } 75 | 76 | /** 77 | * @deprecated 78 | * This method is deprecated, use [[isRt]] instead. 79 | * @param bool $isRuntime whether this index is a real-time index. 80 | * @since 2.0.9 81 | */ 82 | public function setIsRuntime($isRuntime) 83 | { 84 | $this->isRt = $isRuntime; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/MatchBuilder.php: -------------------------------------------------------------------------------- 1 | 21 | * @author Paul Klimov 22 | * @since 2.0.6 23 | */ 24 | class MatchBuilder extends BaseObject 25 | { 26 | /** 27 | * The prefix for automatically generated query binding parameters. 28 | */ 29 | const PARAM_PREFIX = ':mp'; 30 | 31 | /** 32 | * @var Connection the Sphinx connection. 33 | */ 34 | public $db; 35 | 36 | /** 37 | * @var array map of MATCH keywords to builder methods. 38 | * These methods are used by [[buildMatch]] to build MATCH expression from array syntax. 39 | */ 40 | protected $matchBuilders = [ 41 | 'AND' => 'buildAndMatch', 42 | 'OR' => 'buildAndMatch', 43 | 'IGNORE' => 'buildIgnoreMatch', 44 | 'PROXIMITY' => 'buildProximityMatch', 45 | 'MAYBE' => 'buildMultipleMatch', 46 | 'SENTENCE' => 'buildMultipleMatch', 47 | 'PARAGRAPH' => 'buildMultipleMatch', 48 | 'ZONE' => 'buildZoneMatch', 49 | 'ZONESPAN' => 'buildZoneMatch', 50 | ]; 51 | /** 52 | * @var array map of MATCH operators. 53 | * These operators are used for replacement of verbose operators. 54 | */ 55 | protected $matchOperators = [ 56 | 'AND' => ' ', 57 | 'OR' => ' | ', 58 | 'NOT' => ' !', 59 | '=' => ' ', 60 | ]; 61 | 62 | 63 | /** 64 | * Constructor. 65 | * @param Connection $connection the Sphinx connection. 66 | * @param array $config name-value pairs that will be used to initialize the object properties 67 | */ 68 | public function __construct($connection, $config = []) 69 | { 70 | $this->db = $connection; 71 | parent::__construct($config); 72 | } 73 | 74 | /** 75 | * Generates the MATCH expression from given [[MatchExpression]] object. 76 | * @param MatchExpression $match the [[MatchExpression]] object from which the MATCH expression will be generated. 77 | * @return string generated MATCH expression. 78 | */ 79 | public function build($match) 80 | { 81 | $params = $match->params; 82 | $expression = $this->buildMatch($match->match, $params); 83 | return $this->parseParams($expression, $params); 84 | } 85 | 86 | /** 87 | * Create MATCH expression. 88 | * @param string|array $match MATCH specification. 89 | * @param array $params the expression parameters to be populated 90 | * @return string the MATCH expression 91 | */ 92 | public function buildMatch($match, &$params) 93 | { 94 | if (empty($match)) { 95 | return ''; 96 | } 97 | 98 | if ($match instanceof Expression) { 99 | return $this->buildMatchValue($match, $params); 100 | } 101 | 102 | if (!is_array($match)) { 103 | return $match; 104 | } 105 | 106 | if (isset($match[0])) { 107 | // operator format: operator, operand 1, operand 2, ... 108 | $operator = strtoupper($match[0]); 109 | if (isset($this->matchBuilders[$operator])) { 110 | $method = $this->matchBuilders[$operator]; 111 | } else { 112 | $method = 'buildSimpleMatch'; 113 | } 114 | array_shift($match); 115 | return $this->$method($operator, $match, $params); 116 | } 117 | 118 | // hash format: 'column1' => 'value1', 'column2' => 'value2', ... 119 | return $this->buildHashMatch($match, $params); 120 | } 121 | 122 | /** 123 | * Creates a MATCH based on column-value pairs. 124 | * @param array $match the match condition 125 | * @param array $params the expression parameters to be populated 126 | * @return string the MATCH expression 127 | */ 128 | public function buildHashMatch($match, &$params) 129 | { 130 | $parts = []; 131 | 132 | foreach ($match as $column => $value) { 133 | $parts[] = $this->buildMatchColumn($column) . ' ' . $this->buildMatchValue($value, $params); 134 | } 135 | 136 | return count($parts) === 1 ? $parts[0] : '(' . implode(') (', $parts) . ')'; 137 | } 138 | 139 | /** 140 | * Connects two or more MATCH expressions with the `AND` or `OR` operator 141 | * @param string $operator the operator which is used for connecting the given operands 142 | * @param array $operands the Match expressions to connect 143 | * @param array $params the expression parameters to be populated 144 | * @return string the MATCH expression 145 | */ 146 | public function buildAndMatch($operator, $operands, &$params) 147 | { 148 | $parts = []; 149 | foreach ($operands as $operand) { 150 | if (is_array($operand) || is_object($operand)) { 151 | $operand = $this->buildMatch($operand, $params); 152 | } 153 | 154 | if ($operand !== '') { 155 | $parts[] = $operand; 156 | } 157 | } 158 | 159 | if (empty($parts)) { 160 | return ''; 161 | } 162 | 163 | return '(' . implode(')' . ($operator === 'OR' ? ' | ' : ' ') . '(', $parts) . ')'; 164 | } 165 | 166 | /** 167 | * Create MAYBE, SENTENCE or PARAGRAPH expressions. 168 | * @param string $operator the operator which is used for Create Match expressions 169 | * @param array $operands the Match expressions 170 | * @param array &$params the expression parameters to be populated 171 | * @return string the MATCH expression 172 | */ 173 | public function buildMultipleMatch($operator, $operands, &$params) 174 | { 175 | if (count($operands) < 3) { 176 | throw new InvalidParamException("Operator '$operator' requires three or more operands."); 177 | } 178 | 179 | $column = array_shift($operands); 180 | 181 | $phNames = []; 182 | 183 | foreach ($operands as $operand) { 184 | $phNames[] = $this->buildMatchValue($operand, $params); 185 | } 186 | 187 | return $this->buildMatchColumn($column) . ' ' . implode(' ' . $operator . ' ', $phNames); 188 | } 189 | 190 | /** 191 | * Create MATCH expressions for zones. 192 | * @param string $operator the operator which is used for Create Match expressions 193 | * @param array $operands the Match expressions 194 | * @param array &$params the expression parameters to be populated 195 | * @return string the MATCH expression 196 | */ 197 | public function buildZoneMatch($operator, $operands, &$params) 198 | { 199 | if (!isset($operands[0])) { 200 | throw new InvalidParamException("Operator '$operator' requires exactly one operand."); 201 | } 202 | 203 | $zones = (array)$operands[0]; 204 | 205 | return "$operator: (" . implode(',', $zones) . ")"; 206 | } 207 | 208 | /** 209 | * Create PROXIMITY expressions 210 | * @param string $operator the operator which is used for Create Match expressions 211 | * @param array $operands the Match expressions 212 | * @param array &$params the expression parameters to be populated 213 | * @return string the MATCH expression 214 | */ 215 | public function buildProximityMatch($operator, $operands, &$params) 216 | { 217 | if (!isset($operands[0], $operands[1], $operands[2])) { 218 | throw new InvalidParamException("Operator '$operator' requires three operands."); 219 | } 220 | 221 | list($column, $value, $proximity) = $operands; 222 | 223 | return $this->buildMatchColumn($column) . ' ' . $this->buildMatchValue($value, $params) . '~' . (int) $proximity; 224 | } 225 | 226 | /** 227 | * Create ignored MATCH expressions 228 | * @param string $operator the operator which is used for Create Match expressions 229 | * @param array $operands the Match expressions 230 | * @param array &$params the expression parameters to be populated 231 | * @return string the MATCH expression 232 | */ 233 | public function buildIgnoreMatch($operator, $operands, &$params) 234 | { 235 | if (!isset($operands[0], $operands[1])) { 236 | throw new InvalidParamException("Operator '$operator' requires two operands."); 237 | } 238 | 239 | list($column, $value) = $operands; 240 | 241 | return $this->buildMatchColumn($column, true) . ' ' . $this->buildMatchValue($value, $params); 242 | } 243 | 244 | /** 245 | * Creates an Match expressions like `"column" operator value`. 246 | * @param string $operator the operator to use. Anything could be used e.g. `>`, `<=`, etc. 247 | * @param array $operands contains two column names. 248 | * @param array $params the expression parameters to be populated 249 | * @return string the MATCH expression 250 | * @throws InvalidParamException on invalid operands count. 251 | */ 252 | public function buildSimpleMatch($operator, $operands, &$params) 253 | { 254 | if (count($operands) !== 2) { 255 | throw new InvalidParamException("Operator '$operator' requires two operands."); 256 | } 257 | 258 | list($column, $value) = $operands; 259 | 260 | if (isset($this->matchOperators[$operator])) { 261 | $operator = $this->matchOperators[$operator]; 262 | } 263 | 264 | return $this->buildMatchColumn($column) . $operator . $this->buildMatchValue($value, $params); 265 | } 266 | 267 | /** 268 | * Create placeholder for expression of MATCH 269 | * @param string|array|Expression $value 270 | * @param array $params the expression parameters to be populated 271 | * @return string the MATCH expression 272 | */ 273 | protected function buildMatchValue($value, &$params) 274 | { 275 | if (empty($value)) { 276 | return '""'; 277 | } 278 | 279 | if ($value instanceof Expression) { 280 | $params = array_merge($params, $value->params); 281 | return $value->expression; 282 | } 283 | 284 | $parts = []; 285 | foreach ((array) $value as $v) { 286 | if ($v instanceof Expression) { 287 | $params = array_merge($params, $v->params); 288 | $parts[] = $v->expression; 289 | } else { 290 | $phName = self::PARAM_PREFIX . count($params); 291 | $parts[] = $phName; 292 | $params[$phName] = $v; 293 | } 294 | } 295 | 296 | return implode(' | ', $parts); 297 | } 298 | 299 | /** 300 | * Created column as string for expression of MATCH 301 | * @param string $column column specification. 302 | * @param bool $ignored whether column should be specified as 'ignored'. 303 | * @return string the column statement. 304 | */ 305 | protected function buildMatchColumn($column, $ignored = false) 306 | { 307 | if (empty($column)) { 308 | return ''; 309 | } 310 | 311 | if ($column === '*') { 312 | return '@*'; 313 | } 314 | 315 | return '@' . ($ignored ? '!' : '') . (strpos($column, ',') === false ? $column : '(' . $column . ')'); 316 | } 317 | 318 | /** 319 | * Returns the actual MATCH expression by inserting parameter values into the corresponding placeholders. 320 | * @param string $expression the expression string which is needed to prepare. 321 | * @param array $params the binding parameters for inserting. 322 | * @return string parsed expression. 323 | */ 324 | protected function parseParams($expression, $params) 325 | { 326 | if (empty($params)) { 327 | return $expression; 328 | } 329 | 330 | foreach ($params as $name => $value) { 331 | if (strncmp($name, ':', 1) !== 0) { 332 | $name = ':' . $name; 333 | } 334 | // unable to use `str_replace()` because particular param name may be a substring of another param name 335 | $pattern = "/" . preg_quote($name, '/') . '\b/'; 336 | $value = '"' . $this->db->escapeMatchValue($value) . '"'; 337 | $expression = preg_replace($pattern, $value, $expression, -1, $cnt); 338 | } 339 | 340 | return $expression; 341 | } 342 | } 343 | -------------------------------------------------------------------------------- /src/MatchExpression.php: -------------------------------------------------------------------------------- 1 | match(new MatchExpression('@title :title', ['title' => 'Yii'])) 25 | * ->all(); 26 | * ``` 27 | * 28 | * You may use [[match()]], [[andMatch()]] and [[orMatch()]] to combine several conditions. 29 | * For example: 30 | * 31 | * ```php 32 | * use yii\sphinx\Query; 33 | * use yii\sphinx\MatchExpression; 34 | * 35 | * $rows = (new Query()) 36 | * ->match( 37 | * // produces '((@title "Yii") (@author "Paul")) | (@content "Sphinx")' : 38 | * (new MatchExpression()) 39 | * ->match(['title' => 'Yii']) 40 | * ->andMatch(['author' => 'Paul']) 41 | * ->orMatch(['content' => 'Sphinx']) 42 | * ) 43 | * ->all(); 44 | * ``` 45 | * 46 | * You may as well compose expressions with special operators like 'MAYBE', 'PROXIMITY' etc. 47 | * For example: 48 | * 49 | * ```php 50 | * use yii\sphinx\Query; 51 | * use yii\sphinx\MatchExpression; 52 | * 53 | * $rows = (new Query()) 54 | * ->match( 55 | * // produces '@title "Yii" MAYBE "Sphinx"' : 56 | * (new MatchExpression())->match([ 57 | * 'maybe', 58 | * 'title', 59 | * 'Yii', 60 | * 'Sphinx', 61 | * ]) 62 | * ) 63 | * ->all(); 64 | * 65 | * $rows = (new Query()) 66 | * ->match( 67 | * // produces '@title "Yii"~10' : 68 | * (new MatchExpression())->match([ 69 | * 'proximity', 70 | * 'title', 71 | * 'Yii', 72 | * 10, 73 | * ]) 74 | * ) 75 | * ->all(); 76 | * ``` 77 | * 78 | * Note: parameters passed via [[params]] or generated from array conditions will be automatically escaped 79 | * using [[Connection::escapeMatchValue()]]. 80 | * 81 | * @see MatchBuilder 82 | * @see https://sphinxsearch.com/docs/current.html#extended-syntax 83 | * 84 | * @author Paul Klimov 85 | * @since 2.0.6 86 | */ 87 | class MatchExpression extends BaseObject 88 | { 89 | /** 90 | * @var string|array|Expression MATCH expression. 91 | * For example: `['title' => 'Yii', 'content' => 'Sphinx']`. 92 | * Note: being specified as a plain string this value will not be quoted or escaped, do not pass 93 | * possible unsecured values (like the ones obtained from HTTP request) as a direct value. 94 | * @see match() 95 | */ 96 | public $match; 97 | /** 98 | * @var array list of match expression parameter values indexed by parameter placeholders. 99 | * For example, `[':name' => 'Dan', ':age' => 31]`. 100 | * These parameters will be automatically escaped using [[Connection::escapeMatchValue()]] and inserted into MATCH 101 | * expression as a quoted strings. 102 | */ 103 | public $params = []; 104 | 105 | 106 | /** 107 | * Constructor. 108 | * @param string $match the MATCH expression 109 | * @param array $params expression parameters. 110 | * @param array $config name-value pairs that will be used to initialize the object properties 111 | */ 112 | public function __construct($match = null, $params = [], $config = []) 113 | { 114 | $this->match = $match; 115 | $this->params = $params; 116 | parent::__construct($config); 117 | } 118 | 119 | /** 120 | * Sets the MATCH expression. 121 | * 122 | * The method requires a `$condition` parameter, and optionally a `$params` parameter 123 | * specifying the values to be parsed into the expression. 124 | * 125 | * The `$condition` parameter should be either a string (e.g. `'@name "John"'`) or an array. 126 | * 127 | * @param string|array|Expression $condition the conditions that should be put in the MATCH expression. 128 | * @param array $params the parameters (name => value) to be parsed into the query. 129 | * @return $this the expression object itself 130 | * @see andMatch() 131 | * @see orMatch() 132 | */ 133 | public function match($condition, $params = []) 134 | { 135 | $this->match = $condition; 136 | $this->addParams($params); 137 | return $this; 138 | } 139 | 140 | /** 141 | * Adds an additional MATCH condition to the existing one. 142 | * The new condition and the existing one will be joined using the 'AND' (' ') operator. 143 | * @param string|array|Expression $condition the new MATCH condition. Please refer to [[match()]] 144 | * on how to specify this parameter. 145 | * @param array $params the parameters (name => value) to be parsed into the query. 146 | * @return $this the expression object itself 147 | * @see match() 148 | * @see orMatch() 149 | */ 150 | public function andMatch($condition, $params = []) 151 | { 152 | if ($this->match === null) { 153 | $this->match = $condition; 154 | } else { 155 | $this->match = ['and', $this->match, $condition]; 156 | } 157 | $this->addParams($params); 158 | return $this; 159 | } 160 | 161 | /** 162 | * Adds an additional MATCH condition to the existing one. 163 | * The new condition and the existing one will be joined using the 'OR' ('|') operator. 164 | * @param string|array|Expression $condition the new WHERE condition. Please refer to [[match()]] 165 | * on how to specify this parameter. 166 | * @param array $params the parameters (name => value) to be parsed into the query. 167 | * @return $this the expression object itself 168 | * @see match() 169 | * @see andMatch() 170 | */ 171 | public function orMatch($condition, $params = []) 172 | { 173 | if ($this->match === null) { 174 | $this->match = $condition; 175 | } else { 176 | $this->match = ['or', $this->match, $condition]; 177 | } 178 | $this->addParams($params); 179 | return $this; 180 | } 181 | 182 | /** 183 | * Sets the parameters to be parsed into the query. 184 | * @param array $params list of expression parameter values indexed by parameter placeholders. 185 | * For example, `[':name' => 'Dan', ':age' => 31]`. 186 | * @return $this the expression object itself 187 | * @see addParams() 188 | */ 189 | public function params($params) 190 | { 191 | $this->params = $params; 192 | return $this; 193 | } 194 | 195 | /** 196 | * Adds additional parameters to be parsed into the query. 197 | * @param array $params list of expression parameter values indexed by parameter placeholders. 198 | * For example, `[':name' => 'Dan', ':age' => 31]`. 199 | * @return $this the expression object itself 200 | * @see params() 201 | */ 202 | public function addParams($params) 203 | { 204 | if (!empty($params)) { 205 | if (empty($this->params)) { 206 | $this->params = $params; 207 | } else { 208 | foreach ($params as $name => $value) { 209 | if (is_int($name)) { 210 | $this->params[] = $value; 211 | } else { 212 | $this->params[$name] = $value; 213 | } 214 | } 215 | } 216 | } 217 | return $this; 218 | } 219 | 220 | /** 221 | * Sets the MATCH part of the query but ignores [[isEmpty()|empty operands]]. 222 | * 223 | * This method is similar to [[match()]]. The main difference is that this method will 224 | * remove [[isEmpty()|empty query operands]]. As a result, this method is best suited 225 | * for building query conditions based on filter values entered by users. 226 | * 227 | * The following code shows the difference between this method and [[match()]]: 228 | * 229 | * ```php 230 | * // MATCH (@title :title) 231 | * $query->filterMatch(['name' => null, 'title' => 'foo']); 232 | * // MATCH (@title :title) 233 | * $query->match(['title' => 20]); 234 | * // MATCH (@name :name @title :title) 235 | * $query->match(['name' => null, 'age' => 20]); 236 | * ``` 237 | * 238 | * Note that unlike [[match()]], you cannot pass binding parameters to this method. 239 | * 240 | * @param array $condition the conditions that should be put in the MATCH part. 241 | * See [[match()]] on how to specify this parameter. 242 | * @return $this the query object itself 243 | * @see where() 244 | * @see andFilterMatch() 245 | * @see orFilterMatch() 246 | * @since 2.0.7 247 | */ 248 | public function filterMatch(array $condition) 249 | { 250 | $condition = $this->filterCondition($condition); 251 | if ($condition !== []) { 252 | $this->match($condition); 253 | } 254 | return $this; 255 | } 256 | 257 | /** 258 | * Adds an additional MATCH condition to the existing one but ignores [[isEmpty()|empty operands]]. 259 | * The new condition and the existing one will be joined using the 'AND' operator. 260 | * 261 | * This method is similar to [[andMatch()]]. The main difference is that this method will 262 | * remove [[isEmpty()|empty query operands]]. As a result, this method is best suited 263 | * for building query conditions based on filter values entered by users. 264 | * 265 | * @param array $condition the new MATCH condition. Please refer to [[match()]] 266 | * on how to specify this parameter. 267 | * @return $this the query object itself 268 | * @see filterMatch() 269 | * @see orFilterMatch() 270 | * @since 2.0.7 271 | */ 272 | public function andFilterMatch(array $condition) 273 | { 274 | $condition = $this->filterCondition($condition); 275 | if ($condition !== []) { 276 | $this->andMatch($condition); 277 | } 278 | return $this; 279 | } 280 | 281 | /** 282 | * Adds an additional MATCH condition to the existing one but ignores [[isEmpty()|empty operands]]. 283 | * The new condition and the existing one will be joined using the 'OR' operator. 284 | * 285 | * This method is similar to [[orMatch()]]. The main difference is that this method will 286 | * remove [[isEmpty()|empty query operands]]. As a result, this method is best suited 287 | * for building query conditions based on filter values entered by users. 288 | * 289 | * @param array $condition the new MATCH condition. Please refer to [[match()]] 290 | * on how to specify this parameter. 291 | * @return $this the query object itself 292 | * @see filterMatch() 293 | * @see andFilterMatch() 294 | * @since 2.0.7 295 | */ 296 | public function orFilterMatch(array $condition) 297 | { 298 | $condition = $this->filterCondition($condition); 299 | if ($condition !== []) { 300 | $this->orMatch($condition); 301 | } 302 | return $this; 303 | } 304 | 305 | /** 306 | * Removes [[isEmpty()|empty operands]] from the given query condition. 307 | * 308 | * @param array $condition the original condition 309 | * @return array the condition with [[isEmpty()|empty operands]] removed. 310 | * @since 2.0.7 311 | */ 312 | protected function filterCondition($condition) 313 | { 314 | if (!is_array($condition)) { 315 | return $condition; 316 | } 317 | 318 | if (!isset($condition[0])) { 319 | // hash format: 'column1' => 'value1', 'column2' => 'value2', ... 320 | foreach ($condition as $name => $value) { 321 | if ($this->isEmpty($value)) { 322 | unset($condition[$name]); 323 | } 324 | } 325 | return $condition; 326 | } 327 | 328 | // operator format: operator, operand 1, operand 2, ... 329 | 330 | $operator = array_shift($condition); 331 | 332 | switch (strtoupper($operator)) { 333 | case 'NOT': 334 | case 'AND': 335 | case 'OR': 336 | foreach ($condition as $i => $operand) { 337 | $subCondition = $this->filterCondition($operand); 338 | if ($this->isEmpty($subCondition)) { 339 | unset($condition[$i]); 340 | } else { 341 | $condition[$i] = $subCondition; 342 | } 343 | } 344 | 345 | if (empty($condition)) { 346 | return []; 347 | } 348 | break; 349 | case 'SENTENCE': 350 | case 'PARAGRAPH': 351 | $column = array_shift($condition); 352 | foreach ($condition as $i => $operand) { 353 | if ($this->isEmpty($operand)) { 354 | unset($condition[$i]); 355 | } 356 | } 357 | 358 | if (empty($condition)) { 359 | return []; 360 | } 361 | 362 | array_unshift($condition, $column); 363 | break; 364 | case 'ZONE': 365 | case 'ZONESPAN': 366 | foreach ($condition as $i => $operand) { 367 | if ($this->isEmpty($operand)) { 368 | unset($condition[$i]); 369 | } 370 | } 371 | 372 | if (empty($condition)) { 373 | return []; 374 | } 375 | break; 376 | default: 377 | if (array_key_exists(1, $condition) && $this->isEmpty($condition[1])) { 378 | return []; 379 | } 380 | } 381 | 382 | array_unshift($condition, $operator); 383 | 384 | return $condition; 385 | } 386 | 387 | /** 388 | * Returns a value indicating whether the give value is "empty". 389 | * 390 | * The value is considered "empty", if one of the following conditions is satisfied: 391 | * 392 | * - it is `null`, 393 | * - an empty string (`''`), 394 | * - a string containing only whitespace characters, 395 | * - or an empty array. 396 | * 397 | * @param mixed $value 398 | * @return bool if the value is empty 399 | * @since 2.0.7 400 | */ 401 | protected function isEmpty($value) 402 | { 403 | return $value === '' || $value === [] || $value === null || is_string($value) && trim($value) === ''; 404 | } 405 | } 406 | -------------------------------------------------------------------------------- /src/Query.php: -------------------------------------------------------------------------------- 1 | select('id, group_id') 29 | * ->from('idx_item') 30 | * ->limit(10); 31 | * // build and execute the query 32 | * $command = $query->createCommand(); 33 | * // $command->sql returns the actual SQL 34 | * $rows = $command->queryAll(); 35 | * ``` 36 | * 37 | * Since Sphinx does not store the original indexed text, the snippets for the rows in query result 38 | * should be build separately via another query. You can simplify this workflow using [[snippetCallback]]. 39 | * 40 | * Warning: even if you do not set any query limit, implicit LIMIT 0,20 is present by default! 41 | * 42 | * @property Connection $connection Sphinx connection instance. 43 | * 44 | * @author Paul Klimov 45 | * @since 2.0 46 | */ 47 | class Query extends \yii\db\Query 48 | { 49 | /** 50 | * @var string|Expression text, which should be searched in fulltext mode. 51 | * This value will be composed into MATCH operator inside the WHERE clause. 52 | * Note: this value will be processed by [[Connection::escapeMatchValue()]], 53 | * if you need to compose complex match condition use [[Expression]], 54 | * see [[match()]] for details. 55 | */ 56 | public $match; 57 | /** 58 | * @var string WITHIN GROUP ORDER BY clause. This is a Sphinx specific extension 59 | * that lets you control how the best row within a group will to be selected. 60 | * The possible value matches the [[orderBy]] one. 61 | */ 62 | public $within; 63 | /** 64 | * @var array per-query options in format: optionName => optionValue 65 | * They will compose OPTION clause. This is a Sphinx specific extension 66 | * that lets you control a number of per-query options. 67 | */ 68 | public $options; 69 | /** 70 | * @var callable PHP callback, which should be used to fetch source data for the snippets. 71 | * Such callback will receive array of query result rows as an argument and must return the 72 | * array of snippet source strings in the order, which match one of incoming rows. 73 | * For example: 74 | * 75 | * ```php 76 | * $query = new Query(); 77 | * $query->from('idx_item') 78 | * ->match('pencil') 79 | * ->snippetCallback(function ($rows) { 80 | * $result = []; 81 | * foreach ($rows as $row) { 82 | * $result[] = file_get_contents('/path/to/index/files/' . $row['id'] . '.txt'); 83 | * } 84 | * return $result; 85 | * }) 86 | * ->all(); 87 | * ``` 88 | */ 89 | public $snippetCallback; 90 | /** 91 | * @var array query options for the call snippet. 92 | */ 93 | public $snippetOptions; 94 | /** 95 | * @var array facet search specifications. 96 | * For example: 97 | * 98 | * ```php 99 | * [ 100 | * 'group_id', 101 | * 'brand_id' => [ 102 | * 'order' => ['COUNT(*)' => SORT_ASC], 103 | * ], 104 | * 'price' => [ 105 | * 'select' => 'INTERVAL(price,200,400,600,800) AS price', 106 | * 'order' => ['FACET()' => SORT_ASC], 107 | * ], 108 | * 'name_in_json' => [ 109 | * 'select' => [new Expression('json_attr.name AS name_in_json')], 110 | * ], 111 | * ] 112 | * ``` 113 | * 114 | * You need to use [[search()]] method in order to fetch facet results. 115 | * 116 | * Note: if you specify custom select for the facet, ensure facet name has corresponding column inside it. 117 | */ 118 | public $facets = []; 119 | /** 120 | * @var bool|string|Expression whether to automatically perform 'SHOW META' query against main one. 121 | * You may set this value to be string or [[Expression]] instance, in this case its value will be used 122 | * as 'LIKE' condition for 'SHOW META' statement. 123 | * You need to use [[search()]] method in order to fetch 'meta' results. 124 | */ 125 | public $showMeta; 126 | /** 127 | * @var int groups limit: to return (no more than) N top matches for each group. 128 | * This option will take effect only if [[groupBy]] is set. 129 | * @since 2.0.6 130 | */ 131 | public $groupLimit; 132 | 133 | /** 134 | * @var Connection the Sphinx connection used to generate the SQL statements. 135 | */ 136 | private $_connection; 137 | 138 | 139 | /** 140 | * @param Connection $connection Sphinx connection instance 141 | * @return $this the query object itself 142 | */ 143 | public function setConnection($connection) 144 | { 145 | $this->_connection = $connection; 146 | 147 | return $this; 148 | } 149 | 150 | /** 151 | * @return Connection Sphinx connection instance 152 | */ 153 | public function getConnection() 154 | { 155 | if ($this->_connection === null) { 156 | $this->_connection = $this->defaultConnection(); 157 | } 158 | 159 | return $this->_connection; 160 | } 161 | 162 | /** 163 | * @return Connection default connection value. 164 | */ 165 | protected function defaultConnection() 166 | { 167 | return Yii::$app->get('sphinx'); 168 | } 169 | 170 | /** 171 | * Creates a Sphinx command that can be used to execute this query. 172 | * @param Connection $db the Sphinx connection used to generate the SQL statement. 173 | * If this parameter is not given, the `sphinx` application component will be used. 174 | * @return Command the created Sphinx command instance. 175 | */ 176 | public function createCommand($db = null) 177 | { 178 | $this->setConnection($db); 179 | $db = $this->getConnection(); 180 | list ($sql, $params) = $db->getQueryBuilder()->build($this); 181 | 182 | return $db->createCommand($sql, $params); 183 | } 184 | 185 | /** 186 | * {@inheritdoc} 187 | */ 188 | public function populate($rows) 189 | { 190 | return parent::populate($this->fillUpSnippets($rows)); 191 | } 192 | 193 | /** 194 | * {@inheritdoc} 195 | */ 196 | public function one($db = null) 197 | { 198 | $row = parent::one($db); 199 | if ($row !== false) { 200 | list ($row) = $this->fillUpSnippets([$row]); 201 | } 202 | 203 | return $row; 204 | } 205 | 206 | /** 207 | * Executes the query and returns the complete search result including e.g. hits, facets. 208 | * @param Connection $db the Sphinx connection used to generate the SQL statement. 209 | * @return array the query results. 210 | */ 211 | public function search($db = null) 212 | { 213 | if (!empty($this->emulateExecution)) { 214 | return [ 215 | 'hits' => [], 216 | 'facets' => [], 217 | 'meta' => [], 218 | ]; 219 | } 220 | 221 | $command = $this->createCommand($db); 222 | $dataReader = $command->query(); 223 | $rawRows = $dataReader->readAll(); 224 | 225 | $facets = []; 226 | foreach ($this->facets as $facetKey => $facetValue) { 227 | $dataReader->nextResult(); 228 | $rawFacetResults = $dataReader->readAll(); 229 | 230 | if (is_numeric($facetKey)) { 231 | $facet = [ 232 | 'name' => $facetValue, 233 | 'value' => $facetValue, 234 | 'count' => 'count(*)', 235 | ]; 236 | } else { 237 | $facet = array_merge( 238 | [ 239 | 'name' => $facetKey, 240 | 'value' => $facetKey, 241 | 'count' => 'count(*)', 242 | ], 243 | $facetValue 244 | ); 245 | } 246 | 247 | foreach ($rawFacetResults as $rawFacetResult) { 248 | $rawFacetResult['value'] = isset($rawFacetResult[strtolower($facet['value'])]) ? $rawFacetResult[strtolower($facet['value'])] : null; 249 | $rawFacetResult['count'] = $rawFacetResult[$facet['count']]; 250 | $facets[$facet['name']][] = $rawFacetResult; 251 | } 252 | } 253 | 254 | $meta = []; 255 | if (!empty($this->showMeta)) { 256 | $dataReader->nextResult(); 257 | $rawMetaResults = $dataReader->readAll(); 258 | foreach ($rawMetaResults as $rawMetaResult) { 259 | $meta[$rawMetaResult['Variable_name']] = $rawMetaResult['Value']; 260 | } 261 | } 262 | 263 | // rows should be populated after all data read from cursor, avoiding possible 'unbuffered query' error 264 | $rows = $this->populate($rawRows); 265 | 266 | return [ 267 | 'hits' => $rows, 268 | 'facets' => $facets, 269 | 'meta' => $meta, 270 | ]; 271 | } 272 | 273 | /** 274 | * Sets the fulltext query text. This text will be composed into 275 | * MATCH operator inside the WHERE clause. 276 | * Note: this value will be processed by [[Connection::escapeMatchValue()]], 277 | * if you need to compose complex match condition use [[Expression]]: 278 | * 279 | * ```php 280 | * $query = new Query(); 281 | * $query->from('my_index') 282 | * ->match(new Expression(':match', ['match' => '@(content) ' . Yii::$app->sphinx->escapeMatchValue($matchValue)])) 283 | * ->all(); 284 | * ``` 285 | * 286 | * @param string|Expression|MatchExpression $query fulltext query text. 287 | * @return $this the query object itself. 288 | */ 289 | public function match($query) 290 | { 291 | $this->match = $query; 292 | return $this; 293 | } 294 | 295 | /** 296 | * {@inheritdoc} 297 | */ 298 | public function join($type, $table, $on = '', $params = []) 299 | { 300 | throw new NotSupportedException('"' . __METHOD__ . '" is not supported.'); 301 | } 302 | 303 | /** 304 | * {@inheritdoc} 305 | */ 306 | public function innerJoin($table, $on = '', $params = []) 307 | { 308 | throw new NotSupportedException('"' . __METHOD__ . '" is not supported.'); 309 | } 310 | 311 | /** 312 | * {@inheritdoc} 313 | */ 314 | public function leftJoin($table, $on = '', $params = []) 315 | { 316 | throw new NotSupportedException('"' . __METHOD__ . '" is not supported.'); 317 | } 318 | 319 | /** 320 | * {@inheritdoc} 321 | */ 322 | public function rightJoin($table, $on = '', $params = []) 323 | { 324 | throw new NotSupportedException('"' . __METHOD__ . '" is not supported.'); 325 | } 326 | 327 | /** 328 | * {@inheritdoc} 329 | * @since 2.0.9 330 | */ 331 | public function getTablesUsedInFrom() 332 | { 333 | // feature not supported, returning a stub: 334 | return []; 335 | } 336 | 337 | /** 338 | * Sets the query options. 339 | * @param array $options query options in format: optionName => optionValue 340 | * @return $this the query object itself 341 | * @see addOptions() 342 | */ 343 | public function options($options) 344 | { 345 | $this->options = $options; 346 | 347 | return $this; 348 | } 349 | 350 | /** 351 | * Adds additional query options. 352 | * @param array $options query options in format: optionName => optionValue 353 | * @return $this the query object itself 354 | * @see options() 355 | */ 356 | public function addOptions($options) 357 | { 358 | if (is_array($this->options)) { 359 | $this->options = array_merge($this->options, $options); 360 | } else { 361 | $this->options = $options; 362 | } 363 | 364 | return $this; 365 | } 366 | 367 | /** 368 | * Sets the WITHIN GROUP ORDER BY part of the query. 369 | * @param string|array $columns the columns (and the directions) to find best row within a group. 370 | * Columns can be specified in either a string (e.g. "id ASC, name DESC") or an array 371 | * (e.g. `['id' => Query::SORT_ASC, 'name' => Query::SORT_DESC]`). 372 | * The method will automatically quote the column names unless a column contains some parenthesis 373 | * (which means the column contains a DB expression). 374 | * @return $this the query object itself 375 | * @see addWithin() 376 | */ 377 | public function within($columns) 378 | { 379 | $this->within = $this->normalizeOrderBy($columns); 380 | 381 | return $this; 382 | } 383 | 384 | /** 385 | * Adds additional WITHIN GROUP ORDER BY columns to the query. 386 | * @param string|array $columns the columns (and the directions) to find best row within a group. 387 | * Columns can be specified in either a string (e.g. "id ASC, name DESC") or an array 388 | * (e.g. `['id' => Query::SORT_ASC, 'name' => Query::SORT_DESC]`). 389 | * The method will automatically quote the column names unless a column contains some parenthesis 390 | * (which means the column contains a DB expression). 391 | * @return $this the query object itself 392 | * @see within() 393 | */ 394 | public function addWithin($columns) 395 | { 396 | $columns = $this->normalizeOrderBy($columns); 397 | if ($this->within === null) { 398 | $this->within = $columns; 399 | } else { 400 | $this->within = array_merge($this->within, $columns); 401 | } 402 | 403 | return $this; 404 | } 405 | 406 | /** 407 | * Sets groups limit: to return (no more than) N top matches for each group. 408 | * This option will take effect only if [[groupBy]] is set. 409 | * @param int $limit group limit. 410 | * @return $this the query object itself. 411 | * @since 2.0.6 412 | */ 413 | public function groupLimit($limit) 414 | { 415 | $this->groupLimit = $limit; 416 | return $this; 417 | } 418 | 419 | /** 420 | * Sets FACET part of the query. 421 | * @param array $facets facet specifications. 422 | * @return $this the query object itself 423 | */ 424 | public function facets($facets) 425 | { 426 | $this->facets = $facets; 427 | return $this; 428 | } 429 | 430 | /** 431 | * Adds additional FACET part of the query. 432 | * @param array $facets facet specifications. 433 | * @return $this the query object itself 434 | */ 435 | public function addFacets($facets) 436 | { 437 | if (is_array($this->facets)) { 438 | $this->facets = array_merge($this->facets, $facets); 439 | } else { 440 | $this->facets = $facets; 441 | } 442 | return $this; 443 | } 444 | 445 | /** 446 | * Sets whether to automatically perform 'SHOW META' for the search query. 447 | * @param bool|string|Expression $showMeta whether to automatically perform 'SHOW META' 448 | * @return $this the query object itself 449 | * @see showMeta 450 | */ 451 | public function showMeta($showMeta) 452 | { 453 | $this->showMeta = $showMeta; 454 | return $this; 455 | } 456 | 457 | /** 458 | * Sets the PHP callback, which should be used to retrieve the source data 459 | * for the snippets building. 460 | * @param callable $callback PHP callback, which should be used to fetch source data for the snippets. 461 | * @return $this the query object itself 462 | * @see snippetCallback 463 | */ 464 | public function snippetCallback($callback) 465 | { 466 | $this->snippetCallback = $callback; 467 | 468 | return $this; 469 | } 470 | 471 | /** 472 | * Sets the call snippets query options. 473 | * @param array $options call snippet options in format: option_name => option_value 474 | * @return $this the query object itself 475 | * @see snippetCallback 476 | */ 477 | public function snippetOptions($options) 478 | { 479 | $this->snippetOptions = $options; 480 | 481 | return $this; 482 | } 483 | 484 | /** 485 | * Fills the query result rows with the snippets built from source determined by 486 | * [[snippetCallback]] result. 487 | * @param array $rows raw query result rows. 488 | * @return array|ActiveRecord[] query result rows with filled up snippets. 489 | */ 490 | protected function fillUpSnippets($rows) 491 | { 492 | if ($this->snippetCallback === null || empty($rows)) { 493 | return $rows; 494 | } 495 | $snippetSources = call_user_func($this->snippetCallback, $rows); 496 | $snippets = $this->callSnippets($snippetSources); 497 | $snippetKey = 0; 498 | foreach ($rows as $key => $row) { 499 | $rows[$key]['snippet'] = $snippets[$snippetKey]; 500 | $snippetKey++; 501 | } 502 | 503 | return $rows; 504 | } 505 | 506 | /** 507 | * Builds a snippets from provided source data. 508 | * @param array $source the source data to extract a snippet from. 509 | * @throws InvalidCallException in case [[match]] is not specified. 510 | * @return array snippets list. 511 | */ 512 | protected function callSnippets(array $source) 513 | { 514 | return $this->callSnippetsInternal($source, $this->from[0]); 515 | } 516 | 517 | /** 518 | * Builds a snippets from provided source data by the given index. 519 | * @param array $source the source data to extract a snippet from. 520 | * @param string $from name of the source index. 521 | * @return array snippets list. 522 | * @throws InvalidCallException in case [[match]] is not specified. 523 | */ 524 | protected function callSnippetsInternal(array $source, $from) 525 | { 526 | $connection = $this->getConnection(); 527 | $match = $this->match; 528 | if ($match === null) { 529 | throw new InvalidCallException('Unable to call snippets: "' . $this->className() . '::match" should be specified.'); 530 | } 531 | 532 | return $connection->createCommand() 533 | ->callSnippets($from, $source, $match, $this->snippetOptions) 534 | ->queryColumn(); 535 | } 536 | 537 | /** 538 | * {@inheritdoc} 539 | */ 540 | protected function queryScalar($selectExpression, $db) 541 | { 542 | if (!empty($this->emulateExecution)) { 543 | return null; 544 | } 545 | 546 | $select = $this->select; 547 | $limit = $this->limit; 548 | $offset = $this->offset; 549 | 550 | $this->select = [$selectExpression]; 551 | $this->limit = null; 552 | $this->offset = null; 553 | $command = $this->createCommand($db); 554 | 555 | $this->select = $select; 556 | $this->limit = $limit; 557 | $this->offset = $offset; 558 | 559 | if (empty($this->groupBy) && empty($this->union) && !$this->distinct) { 560 | return $command->queryScalar(); 561 | } 562 | 563 | return (new Query)->select([$selectExpression]) 564 | ->from(['c' => $this]) 565 | ->createCommand($command->db) 566 | ->queryScalar(); 567 | } 568 | 569 | /** 570 | * Creates a new Query object and copies its property values from an existing one. 571 | * The properties being copies are the ones to be used by query builders. 572 | * @param Query $from the source query object 573 | * @return Query the new Query object 574 | */ 575 | public static function create($from) 576 | { 577 | return new self([ 578 | 'where' => $from->where, 579 | 'limit' => $from->limit, 580 | 'offset' => $from->offset, 581 | 'orderBy' => $from->orderBy, 582 | 'indexBy' => $from->indexBy, 583 | 'select' => $from->select, 584 | 'selectOption' => $from->selectOption, 585 | 'distinct' => $from->distinct, 586 | 'from' => $from->from, 587 | 'groupBy' => $from->groupBy, 588 | 'join' => $from->join, 589 | 'having' => $from->having, 590 | 'union' => $from->union, 591 | 'params' => $from->params, 592 | // Sphinx specifics : 593 | 'groupLimit' => $from->groupLimit, 594 | 'options' => $from->options, 595 | 'within' => $from->within, 596 | 'match' => $from->match, 597 | 'snippetCallback' => $from->snippetCallback, 598 | 'snippetOptions' => $from->snippetOptions, 599 | ]); 600 | } 601 | } 602 | -------------------------------------------------------------------------------- /src/Schema.php: -------------------------------------------------------------------------------- 1 | index type. 23 | * @property-read QueryBuilder $queryBuilder The query builder for this connection. 24 | * 25 | * @author Paul Klimov 26 | * @since 2.0 27 | */ 28 | class Schema extends BaseObject 29 | { 30 | /** 31 | * The following are the supported abstract column data types. 32 | */ 33 | const TYPE_PK = 'pk'; 34 | const TYPE_STRING = 'string'; 35 | const TYPE_INTEGER = 'integer'; 36 | const TYPE_BIGINT = 'bigint'; 37 | const TYPE_FLOAT = 'float'; 38 | const TYPE_TIMESTAMP = 'timestamp'; 39 | const TYPE_BOOLEAN = 'boolean'; 40 | 41 | /** 42 | * @var Connection the Sphinx connection 43 | */ 44 | public $db; 45 | /** 46 | * @var array list of ALL index names in the Sphinx 47 | */ 48 | private $_indexNames; 49 | /** 50 | * @var array list of ALL index types in the Sphinx (index name => index type) 51 | */ 52 | private $_indexTypes; 53 | /** 54 | * @var array list of loaded index metadata (index name => IndexSchema) 55 | */ 56 | private $_indexes = []; 57 | /** 58 | * @var QueryBuilder the query builder for this Sphinx connection 59 | */ 60 | private $_builder; 61 | 62 | 63 | /** 64 | * @var array mapping from physical column types (keys) to abstract column types (values) 65 | */ 66 | public $typeMap = [ 67 | 'field' => self::TYPE_STRING, 68 | 'string' => self::TYPE_STRING, 69 | 'ordinal' => self::TYPE_STRING, 70 | 'integer' => self::TYPE_INTEGER, 71 | 'int' => self::TYPE_INTEGER, 72 | 'uint' => self::TYPE_INTEGER, 73 | 'bigint' => self::TYPE_BIGINT, 74 | 'timestamp' => self::TYPE_TIMESTAMP, 75 | 'bool' => self::TYPE_BOOLEAN, 76 | 'float' => self::TYPE_FLOAT, 77 | 'mva' => self::TYPE_INTEGER, 78 | 'uint_set' => self::TYPE_INTEGER, 79 | ]; 80 | 81 | /** 82 | * Loads the metadata for the specified index. 83 | * @param string $name index name 84 | * @return IndexSchema|null driver dependent index metadata. `null` - if the index does not exist. 85 | */ 86 | protected function loadIndexSchema($name) 87 | { 88 | $index = new IndexSchema(); 89 | $this->resolveIndexNames($index, $name); 90 | $this->resolveIndexType($index); 91 | 92 | if ($this->findColumns($index)) { 93 | return $index; 94 | } 95 | return null; 96 | } 97 | 98 | /** 99 | * Resolves the index name. 100 | * @param IndexSchema $index the index metadata object 101 | * @param string $name the index name 102 | */ 103 | protected function resolveIndexNames($index, $name) 104 | { 105 | $index->name = str_replace('`', '', $name); 106 | } 107 | 108 | /** 109 | * Resolves the index name. 110 | * @param IndexSchema $index the index metadata object 111 | */ 112 | protected function resolveIndexType($index) 113 | { 114 | $indexTypes = $this->getIndexTypes(); 115 | $index->type = array_key_exists($index->name, $indexTypes) ? $indexTypes[$index->name] : 'unknown'; 116 | $index->isRt = ($index->type == 'rt'); 117 | } 118 | 119 | /** 120 | * Obtains the metadata for the named index. 121 | * @param string $name index name. The index name may contain schema name if any. Do not quote the index name. 122 | * @param bool $refresh whether to reload the index schema even if it is found in the cache. 123 | * @return IndexSchema|null index metadata. `null` - if the named index does not exist. 124 | */ 125 | public function getIndexSchema($name, $refresh = false) 126 | { 127 | if (isset($this->_indexes[$name]) && !$refresh) { 128 | return $this->_indexes[$name]; 129 | } 130 | 131 | $db = $this->db; 132 | $realName = $this->getRawIndexName($name); 133 | 134 | if ($db->enableSchemaCache && !in_array($name, $db->schemaCacheExclude, true)) { 135 | /* @var $cache Cache */ 136 | $cache = is_string($db->schemaCache) ? Yii::$app->get($db->schemaCache, false) : $db->schemaCache; 137 | if ($cache instanceof Cache) { 138 | $key = $this->getCacheKey($name); 139 | if ($refresh || ($index = $cache->get($key)) === false) { 140 | $index = $this->loadIndexSchema($realName); 141 | if ($index !== null) { 142 | $cache->set($key, $index, $db->schemaCacheDuration, new TagDependency([ 143 | 'tags' => $this->getCacheTag(), 144 | ])); 145 | } 146 | } 147 | 148 | return $this->_indexes[$name] = $index; 149 | } 150 | } 151 | 152 | return $this->_indexes[$name] = $this->loadIndexSchema($realName); 153 | } 154 | 155 | /** 156 | * Returns the cache key for the specified index name. 157 | * @param string $name the index name 158 | * @return mixed the cache key 159 | */ 160 | protected function getCacheKey($name) 161 | { 162 | return [ 163 | __CLASS__, 164 | $this->db->dsn, 165 | $this->db->username, 166 | $name, 167 | ]; 168 | } 169 | 170 | /** 171 | * Returns the cache tag name. 172 | * This allows [[refresh()]] to invalidate all cached index schemas. 173 | * @return string the cache tag name 174 | */ 175 | protected function getCacheTag() 176 | { 177 | return md5(serialize([ 178 | __CLASS__, 179 | $this->db->dsn, 180 | $this->db->username, 181 | ])); 182 | } 183 | 184 | /** 185 | * Returns the metadata for all indexes in the database. 186 | * @param bool $refresh whether to fetch the latest available index schemas. If this is false, 187 | * cached data may be returned if available. 188 | * @return IndexSchema[] the metadata for all indexes in the Sphinx. 189 | * Each array element is an instance of [[IndexSchema]] or its child class. 190 | */ 191 | public function getIndexSchemas($refresh = false) 192 | { 193 | $indexes = []; 194 | foreach ($this->getIndexNames($refresh) as $name) { 195 | if (($index = $this->getIndexSchema($name, $refresh)) !== null) { 196 | $indexes[] = $index; 197 | } 198 | } 199 | 200 | return $indexes; 201 | } 202 | 203 | /** 204 | * Returns all index names in the Sphinx. 205 | * @param bool $refresh whether to fetch the latest available index names. If this is false, 206 | * index names fetched previously (if available) will be returned. 207 | * @return string[] all index names in the Sphinx. 208 | */ 209 | public function getIndexNames($refresh = false) 210 | { 211 | if (!isset($this->_indexNames) || $refresh) { 212 | $this->initIndexesInfo(); 213 | } 214 | 215 | return $this->_indexNames; 216 | } 217 | 218 | /** 219 | * Returns all index types in the Sphinx. 220 | * @param bool $refresh whether to fetch the latest available index types. If this is false, 221 | * index types fetched previously (if available) will be returned. 222 | * @return array all index types in the Sphinx in format: index name => index type. 223 | */ 224 | public function getIndexTypes($refresh = false) 225 | { 226 | if (!isset($this->_indexTypes) || $refresh) { 227 | $this->initIndexesInfo(); 228 | } 229 | 230 | return $this->_indexTypes; 231 | } 232 | 233 | /** 234 | * Initializes information about name and type of all index in the Sphinx. 235 | */ 236 | protected function initIndexesInfo() 237 | { 238 | $this->_indexNames = []; 239 | $this->_indexTypes = []; 240 | $indexes = $this->findIndexes(); 241 | foreach ($indexes as $index) { 242 | $indexName = $index['Index']; 243 | $this->_indexNames[] = $indexName; 244 | $this->_indexTypes[$indexName] = $index['Type']; 245 | } 246 | } 247 | 248 | /** 249 | * Returns all index names in the Sphinx. 250 | * @return array all index names in the Sphinx. 251 | */ 252 | protected function findIndexes() 253 | { 254 | $sql = 'SHOW TABLES'; 255 | 256 | return $this->db->createCommand($sql)->queryAll(); 257 | } 258 | 259 | /** 260 | * @return QueryBuilder the query builder for this connection. 261 | */ 262 | public function getQueryBuilder() 263 | { 264 | if ($this->_builder === null) { 265 | $this->_builder = $this->createQueryBuilder(); 266 | } 267 | 268 | return $this->_builder; 269 | } 270 | 271 | /** 272 | * Determines the PDO type for the given PHP data value. 273 | * @param mixed $data the data whose PDO type is to be determined 274 | * @return int the PDO type 275 | * @see https://www.php.net/manual/en/pdo.constants.php 276 | */ 277 | public function getPdoType($data) 278 | { 279 | static $typeMap = [ 280 | // php type => PDO type 281 | 'bool' => \PDO::PARAM_BOOL, 282 | 'boolean' => \PDO::PARAM_BOOL, 283 | 'int' => \PDO::PARAM_INT, 284 | 'integer' => \PDO::PARAM_INT, 285 | 'string' => \PDO::PARAM_STR, 286 | 'resource' => \PDO::PARAM_LOB, 287 | 'NULL' => \PDO::PARAM_NULL, 288 | ]; 289 | $type = gettype($data); 290 | 291 | return isset($typeMap[$type]) ? $typeMap[$type] : \PDO::PARAM_STR; 292 | } 293 | 294 | /** 295 | * Refreshes the schema. 296 | * This method cleans up all cached index schemas so that they can be re-created later 297 | * to reflect the Sphinx schema change. 298 | */ 299 | public function refresh() 300 | { 301 | /* @var $cache Cache */ 302 | $cache = is_string($this->db->schemaCache) ? Yii::$app->get($this->db->schemaCache, false) : $this->db->schemaCache; 303 | if ($this->db->enableSchemaCache && $cache instanceof Cache) { 304 | TagDependency::invalidate($cache, $this->getCacheTag()); 305 | } 306 | $this->_indexNames = []; 307 | $this->_indexes = []; 308 | } 309 | 310 | /** 311 | * Creates a query builder for the Sphinx. 312 | * @return QueryBuilder query builder instance 313 | */ 314 | public function createQueryBuilder() 315 | { 316 | return new QueryBuilder($this->db); 317 | } 318 | 319 | /** 320 | * Quotes a string value for use in a query. 321 | * Note that if the parameter is not a string, it will be returned without change. 322 | * @param string $str string to be quoted 323 | * @return string the properly quoted string 324 | * @see https://www.php.net/manual/en/function.PDO-quote.php 325 | */ 326 | public function quoteValue($str) 327 | { 328 | if (is_string($str)) { 329 | return $this->db->getSlavePdo()->quote($str); 330 | } 331 | return $str; 332 | } 333 | 334 | /** 335 | * Quotes a index name for use in a query. 336 | * If the index name contains schema prefix, the prefix will also be properly quoted. 337 | * If the index name is already quoted or contains '(' or '{{', 338 | * then this method will do nothing. 339 | * @param string $name index name 340 | * @return string the properly quoted index name 341 | * @see quoteSimpleTableName 342 | */ 343 | public function quoteIndexName($name) 344 | { 345 | if (strpos($name, '(') !== false || strpos($name, '{{') !== false) { 346 | return $name; 347 | } 348 | 349 | return $this->quoteSimpleIndexName($name); 350 | } 351 | 352 | /** 353 | * Quotes a column name for use in a query. 354 | * If the column name contains prefix, the prefix will also be properly quoted. 355 | * If the column name is already quoted or contains '(', '[[' or '{{', 356 | * then this method will do nothing. 357 | * @param string $name column name 358 | * @return string the properly quoted column name 359 | * @see quoteSimpleColumnName 360 | */ 361 | public function quoteColumnName($name) 362 | { 363 | if (strpos($name, '(') !== false || strpos($name, '[[') !== false || strpos($name, '{{') !== false) { 364 | return $name; 365 | } 366 | if (($pos = strrpos($name, '.')) !== false) { 367 | $prefix = $this->quoteIndexName(substr($name, 0, $pos)) . '.'; 368 | $name = substr($name, $pos + 1); 369 | } else { 370 | $prefix = ''; 371 | } 372 | 373 | return $prefix . $this->quoteSimpleColumnName($name); 374 | } 375 | 376 | /** 377 | * Quotes a index name for use in a query. 378 | * A simple index name has no schema prefix. 379 | * @param string $name index name 380 | * @return string the properly quoted index name 381 | */ 382 | public function quoteSimpleIndexName($name) 383 | { 384 | return strpos($name, "`") !== false ? $name : "`" . $name . "`"; 385 | } 386 | 387 | /** 388 | * Quotes a column name for use in a query. 389 | * A simple column name has no prefix. 390 | * @param string $name column name 391 | * @return string the properly quoted column name 392 | */ 393 | public function quoteSimpleColumnName($name) 394 | { 395 | return strpos($name, '`') !== false || $name === '*' ? $name : '`' . $name . '`'; 396 | } 397 | 398 | /** 399 | * Returns the actual name of a given index name. 400 | * This method will strip off curly brackets from the given index name 401 | * and replace the percentage character '%' with [[Connection::indexPrefix]]. 402 | * @param string $name the index name to be converted 403 | * @return string the real name of the given index name 404 | */ 405 | public function getRawIndexName($name) 406 | { 407 | if (strpos($name, '{{') !== false) { 408 | $name = preg_replace('/\\{\\{(.*?)\\}\\}/', '\1', $name); 409 | 410 | return str_replace('%', $this->db->tablePrefix, $name); 411 | } else { 412 | return $name; 413 | } 414 | } 415 | 416 | /** 417 | * Extracts the PHP type from abstract DB type. 418 | * @param ColumnSchema $column the column schema information 419 | * @return string PHP type name 420 | */ 421 | protected function getColumnPhpType($column) 422 | { 423 | static $typeMap = [ // abstract type => php type 424 | 'smallint' => 'integer', 425 | 'integer' => 'integer', 426 | 'bigint' => 'integer', 427 | 'timestamp' => 'integer', 428 | 'boolean' => 'boolean', 429 | 'float' => 'double', 430 | ]; 431 | if (isset($typeMap[$column->type])) { 432 | if ($column->type === 'bigint') { 433 | return PHP_INT_SIZE == 8 ? 'integer' : 'string'; 434 | } elseif ($column->type === 'integer' || $column->type === 'timestamp') { 435 | return PHP_INT_SIZE == 4 ? 'string' : 'integer'; 436 | } else { 437 | return $typeMap[$column->type]; 438 | } 439 | } else { 440 | return 'string'; 441 | } 442 | } 443 | 444 | /** 445 | * Collects the metadata of index columns. 446 | * @param IndexSchema $index the index metadata 447 | * @return bool whether the index exists in the database 448 | * @throws \Exception if DB query fails 449 | */ 450 | protected function findColumns($index) 451 | { 452 | $sql = 'DESCRIBE ' . $this->quoteSimpleIndexName($index->name); 453 | try { 454 | $columns = $this->db->createCommand($sql)->queryAll(); 455 | } catch (\Exception $e) { 456 | $previous = $e->getPrevious(); 457 | if ($previous instanceof \PDOException && strpos($previous->getMessage(), 'SQLSTATE[42S02') !== false) { 458 | // index does not exist 459 | // https://dev.mysql.com/doc/refman/5.5/en/error-messages-server.html#error_er_bad_table_error 460 | return false; 461 | } 462 | throw $e; 463 | } 464 | 465 | if (empty($columns[0]['Agent'])) { 466 | foreach ($columns as $info) { 467 | $column = $this->loadColumnSchema($info); 468 | if (isset($index->columns[$column->name])) { 469 | $column = $this->mergeColumnSchema($index->columns[$column->name], $column); 470 | } 471 | $index->columns[$column->name] = $column; 472 | if ($column->isPrimaryKey) { 473 | $index->primaryKey = $column->name; 474 | } 475 | } 476 | 477 | return true; 478 | } 479 | 480 | // Distributed index : 481 | foreach ($columns as $column) { 482 | if (!empty($column['Type']) && strcasecmp('local', $column['Type']) !== 0) { 483 | // skip type 'agent' 484 | continue; 485 | } 486 | 487 | $agent = $this->getIndexSchema($column['Agent']); 488 | if ($agent !== null) { 489 | $index->columns = $agent->columns; 490 | $index->primaryKey = $agent->primaryKey; 491 | return true; 492 | } 493 | } 494 | 495 | $this->applyDefaultColumns($index); 496 | 497 | return true; 498 | } 499 | 500 | /** 501 | * Sets up the default columns for given index. 502 | * This method should be used in case there is no way to find actual columns, 503 | * like in some distributed indexes. 504 | * @param IndexSchema $index the index metadata 505 | * @since 2.0.6 506 | */ 507 | protected function applyDefaultColumns($index) 508 | { 509 | $column = $this->loadColumnSchema([ 510 | 'Field' => 'id', 511 | 'Type' => 'bigint', 512 | ]); 513 | $index->columns[$column->name] = $column; 514 | $index->primaryKey = 'id'; 515 | } 516 | 517 | /** 518 | * Loads the column information into a [[ColumnSchema]] object. 519 | * @param array $info column information 520 | * @return ColumnSchema the column schema object 521 | */ 522 | protected function loadColumnSchema($info) 523 | { 524 | $column = new ColumnSchema(); 525 | 526 | $column->name = $info['Field']; 527 | $column->dbType = $info['Type']; 528 | 529 | $column->isPrimaryKey = ($column->name === 'id'); 530 | 531 | $type = $info['Type']; 532 | if (isset($this->typeMap[$type])) { 533 | $column->type = $this->typeMap[$type]; 534 | } else { 535 | $column->type = self::TYPE_STRING; 536 | } 537 | 538 | $column->isField = ($type === 'field'); 539 | $column->isAttribute = !$column->isField; 540 | 541 | $column->isMva = $type === 'mva' || $type === 'uint_set'; 542 | 543 | $column->phpType = $this->getColumnPhpType($column); 544 | 545 | return $column; 546 | } 547 | 548 | /** 549 | * Merges two column schemas into a single one. 550 | * @param ColumnSchema $origin original column schema. 551 | * @param ColumnSchema $override column schema to be applied over original one. 552 | * @return ColumnSchema merge result. 553 | * @since 2.0.10 554 | */ 555 | protected function mergeColumnSchema($origin, $override) 556 | { 557 | $override->dbType .= ',' . $override->dbType; 558 | 559 | if ($override->isField) { 560 | $origin->isField = true; 561 | } 562 | if ($override->isAttribute) { 563 | $origin->isAttribute = true; 564 | $origin->type = $override->type; 565 | $origin->phpType = $override->phpType; 566 | 567 | if ($override->isMva) { 568 | $origin->isMva = true; 569 | } 570 | } 571 | 572 | return $origin; 573 | } 574 | 575 | /** 576 | * Converts a DB exception to a more concrete one if possible. 577 | * 578 | * @param \Exception $e 579 | * @param string $rawSql SQL that produced exception 580 | * @return Exception 581 | */ 582 | public function convertException(\Exception $e, $rawSql) 583 | { 584 | if ($e instanceof Exception) { 585 | return $e; 586 | } 587 | $message = $e->getMessage() . "\nThe SQL being executed was: $rawSql"; 588 | $errorInfo = $e instanceof \PDOException ? $e->errorInfo : null; 589 | return new Exception($message, $errorInfo, (int) $e->getCode(), $e); 590 | } 591 | 592 | /** 593 | * Returns a value indicating whether a SQL statement is for read purpose. 594 | * @param string $sql the SQL statement 595 | * @return bool whether a SQL statement is for read purpose. 596 | */ 597 | public function isReadQuery($sql) 598 | { 599 | $pattern = '/^\s*(SELECT|SHOW|DESCRIBE)\b/i'; 600 | return preg_match($pattern, $sql) > 0; 601 | } 602 | } 603 | -------------------------------------------------------------------------------- /src/gii/model/Generator.php: -------------------------------------------------------------------------------- 1 | 21 | * @since 2.0 22 | */ 23 | class Generator extends \yii\gii\Generator 24 | { 25 | public $db = 'sphinx'; 26 | public $ns = 'app\models'; 27 | public $indexName; 28 | public $modelClass; 29 | public $baseClass = 'yii\sphinx\ActiveRecord'; 30 | public $useIndexPrefix = false; 31 | 32 | 33 | /** 34 | * {@inheritdoc} 35 | */ 36 | public function getName() 37 | { 38 | return 'Sphinx Model Generator'; 39 | } 40 | 41 | /** 42 | * {@inheritdoc} 43 | */ 44 | public function getDescription() 45 | { 46 | return 'This generator generates an ActiveRecord class for the specified Sphinx index.'; 47 | } 48 | 49 | /** 50 | * {@inheritdoc} 51 | */ 52 | public function rules() 53 | { 54 | return array_merge(parent::rules(), [ 55 | [['db', 'ns', 'indexName', 'modelClass', 'baseClass'], 'filter', 'filter' => 'trim'], 56 | [['ns'], 'filter', 'filter' => function($value) { return trim($value, '\\'); }], 57 | 58 | [['db', 'ns', 'indexName', 'baseClass'], 'required'], 59 | [['db', 'modelClass'], 'match', 'pattern' => '/^\w+$/', 'message' => 'Only word characters are allowed.'], 60 | [['ns', 'baseClass'], 'match', 'pattern' => '/^[\w\\\\]+$/', 'message' => 'Only word characters and backslashes are allowed.'], 61 | [['indexName'], 'match', 'pattern' => '/^(\w+\.)?([\w\*]+)$/', 'message' => 'Only word characters, and optionally an asterisk and/or a dot are allowed.'], 62 | [['db'], 'validateDb'], 63 | [['ns'], 'validateNamespace'], 64 | [['indexName'], 'validateIndexName'], 65 | [['modelClass'], 'validateModelClass', 'skipOnEmpty' => false], 66 | [['baseClass'], 'validateClass', 'params' => ['extends' => ActiveRecord::className()]], 67 | [['enableI18N'], 'boolean'], 68 | [['useIndexPrefix'], 'boolean'], 69 | [['messageCategory'], 'validateMessageCategory', 'skipOnEmpty' => false], 70 | ]); 71 | } 72 | 73 | /** 74 | * {@inheritdoc} 75 | */ 76 | public function attributeLabels() 77 | { 78 | return array_merge(parent::attributeLabels(), [ 79 | 'ns' => 'Namespace', 80 | 'db' => 'Sphinx Connection ID', 81 | 'indexName' => 'Index Name', 82 | 'modelClass' => 'Model Class', 83 | 'baseClass' => 'Base Class', 84 | ]); 85 | } 86 | 87 | /** 88 | * {@inheritdoc} 89 | */ 90 | public function hints() 91 | { 92 | return array_merge(parent::hints(), [ 93 | 'ns' => 'This is the namespace of the ActiveRecord class to be generated, e.g., app\models', 94 | 'db' => 'This is the ID of the Sphinx application component.', 95 | 'indexName' => 'This is the name of the Sphinx index that the new ActiveRecord class is associated with, e.g. post. 96 | The index name may end with asterisk to match multiple table names, e.g. idx_* 97 | will match indexes, which name starts with idx_. In this case, multiple ActiveRecord classes 98 | will be generated, one for each matching index name; and the class names will be generated from 99 | the matching characters. For example, index idx_post will generate Post 100 | class.', 101 | 'modelClass' => 'This is the name of the ActiveRecord class to be generated. The class name should not contain 102 | the namespace part as it is specified in "Namespace". You do not need to specify the class name 103 | if "Index Name" ends with asterisk, in which case multiple ActiveRecord classes will be generated.', 104 | 'baseClass' => 'This is the base class of the new ActiveRecord class. It should be a fully qualified namespaced class name.', 105 | 'useIndexPrefix' => 'This indicates whether the index name returned by the generated ActiveRecord class 106 | should consider the tablePrefix setting of the Sphinx connection. For example, if the 107 | index name is idx_post and tablePrefix=idx_, the ActiveRecord class 108 | will return the table name as {{%post}}.', 109 | ]); 110 | } 111 | 112 | /** 113 | * {@inheritdoc} 114 | */ 115 | public function autoCompleteData() 116 | { 117 | $db = $this->getDbConnection(); 118 | if ($db !== null) { 119 | return [ 120 | 'indexName' => function () use ($db) { 121 | return $db->getSchema()->getIndexNames(); 122 | }, 123 | ]; 124 | } else { 125 | return []; 126 | } 127 | } 128 | 129 | /** 130 | * {@inheritdoc} 131 | */ 132 | public function requiredTemplates() 133 | { 134 | return ['model.php']; 135 | } 136 | 137 | /** 138 | * {@inheritdoc} 139 | */ 140 | public function stickyAttributes() 141 | { 142 | return array_merge(parent::stickyAttributes(), ['ns', 'db', 'baseClass']); 143 | } 144 | 145 | /** 146 | * {@inheritdoc} 147 | */ 148 | public function generate() 149 | { 150 | $files = []; 151 | $db = $this->getDbConnection(); 152 | foreach ($this->getIndexNames() as $indexName) { 153 | $className = $this->generateClassName($indexName); 154 | $indexSchema = $db->getIndexSchema($indexName); 155 | $params = [ 156 | 'indexName' => $indexName, 157 | 'className' => $className, 158 | 'indexSchema' => $indexSchema, 159 | 'labels' => $this->generateLabels($indexSchema), 160 | 'rules' => $this->generateRules($indexSchema), 161 | ]; 162 | $files[] = new CodeFile( 163 | Yii::getAlias('@' . str_replace('\\', '/', $this->ns)) . '/' . $className . '.php', 164 | $this->render('model.php', $params) 165 | ); 166 | } 167 | 168 | return $files; 169 | } 170 | 171 | /** 172 | * Generates the attribute labels for the specified table. 173 | * @param \yii\db\TableSchema $table the table schema 174 | * @return array the generated attribute labels (name => label) 175 | */ 176 | public function generateLabels($table) 177 | { 178 | $labels = []; 179 | foreach ($table->columns as $column) { 180 | if (!strcasecmp($column->name, 'id')) { 181 | $labels[$column->name] = 'ID'; 182 | } else { 183 | $label = Inflector::camel2words($column->name); 184 | if (substr_compare($label, ' id', -3, 3, true) === 0) { 185 | $label = substr($label, 0, -3) . ' ID'; 186 | } 187 | $labels[$column->name] = $label; 188 | } 189 | } 190 | 191 | return $labels; 192 | } 193 | 194 | /** 195 | * Generates validation rules for the specified index. 196 | * @param \yii\sphinx\IndexSchema $index the index schema 197 | * @return array the generated validation rules 198 | */ 199 | public function generateRules($index) 200 | { 201 | $types = []; 202 | foreach ($index->columns as $column) { 203 | if ($column->isMva) { 204 | $types['safe'][] = $column->name; 205 | continue; 206 | } 207 | if ($column->isPrimaryKey) { 208 | $types['required'][] = $column->name; 209 | $types['unique'][] = $column->name; 210 | } 211 | switch ($column->type) { 212 | case Schema::TYPE_PK: 213 | case Schema::TYPE_INTEGER: 214 | case Schema::TYPE_BIGINT: 215 | $types['integer'][] = $column->name; 216 | break; 217 | case Schema::TYPE_BOOLEAN: 218 | $types['boolean'][] = $column->name; 219 | break; 220 | case Schema::TYPE_FLOAT: 221 | $types['number'][] = $column->name; 222 | break; 223 | case Schema::TYPE_TIMESTAMP: 224 | $types['safe'][] = $column->name; 225 | break; 226 | default: // strings 227 | $types['string'][] = $column->name; 228 | } 229 | } 230 | $rules = []; 231 | foreach ($types as $type => $columns) { 232 | $rules[] = "[['" . implode("', '", $columns) . "'], '$type']"; 233 | } 234 | 235 | return $rules; 236 | } 237 | 238 | /** 239 | * Validates the [[db]] attribute. 240 | */ 241 | public function validateDb() 242 | { 243 | if (!Yii::$app->has($this->db)) { 244 | $this->addError('db', 'There is no application component named "' . $this->db . '".'); 245 | } elseif (!Yii::$app->get($this->db) instanceof Connection) { 246 | $this->addError('db', 'The "' . $this->db . '" application component must be a Sphinx connection instance.'); 247 | } 248 | } 249 | 250 | /** 251 | * Validates the [[ns]] attribute. 252 | */ 253 | public function validateNamespace() 254 | { 255 | $this->ns = ltrim($this->ns, '\\'); 256 | $path = Yii::getAlias('@' . str_replace('\\', '/', $this->ns), false); 257 | if ($path === false) { 258 | $this->addError('ns', 'Namespace must be associated with an existing directory.'); 259 | } 260 | } 261 | 262 | /** 263 | * Validates the [[modelClass]] attribute. 264 | */ 265 | public function validateModelClass() 266 | { 267 | if ($this->isReservedKeyword($this->modelClass)) { 268 | $this->addError('modelClass', 'Class name cannot be a reserved PHP keyword.'); 269 | } 270 | if ((empty($this->indexName) || substr_compare($this->indexName, '*', -1, 1)) && $this->modelClass == '') { 271 | $this->addError('modelClass', 'Model Class cannot be blank if table name does not end with asterisk.'); 272 | } 273 | } 274 | 275 | /** 276 | * Validates the [[indexName]] attribute. 277 | */ 278 | public function validateIndexName() 279 | { 280 | if (strpos($this->indexName, '*') !== false && substr_compare($this->indexName, '*', -1, 1)) { 281 | $this->addError('indexName', 'Asterisk is only allowed as the last character.'); 282 | 283 | return; 284 | } 285 | $tables = $this->getIndexNames(); 286 | if (empty($tables)) { 287 | $this->addError('indexName', "Table '{$this->indexName}' does not exist."); 288 | } else { 289 | foreach ($tables as $table) { 290 | $class = $this->generateClassName($table); 291 | if ($this->isReservedKeyword($class)) { 292 | $this->addError('indexName', "Table '$table' will generate a class which is a reserved PHP keyword."); 293 | break; 294 | } 295 | } 296 | } 297 | } 298 | 299 | private $_indexNames; 300 | private $_classNames; 301 | 302 | /** 303 | * @return array the index names that match the pattern specified by [[indexName]]. 304 | */ 305 | protected function getIndexNames() 306 | { 307 | if ($this->_indexNames !== null) { 308 | return $this->_indexNames; 309 | } 310 | $db = $this->getDbConnection(); 311 | if ($db === null) { 312 | return []; 313 | } 314 | $indexNames = []; 315 | if (strpos($this->indexName, '*') !== false) { 316 | $indexNames = $db->getSchema()->getIndexNames(); 317 | } elseif (($index = $db->getIndexSchema($this->indexName, true)) !== null) { 318 | $indexNames[] = $this->indexName; 319 | $this->_classNames[$this->indexName] = $this->modelClass; 320 | } 321 | 322 | return $this->_indexNames = $indexNames; 323 | } 324 | 325 | /** 326 | * Generates the table name by considering table prefix. 327 | * If [[useIndexPrefix]] is false, the table name will be returned without change. 328 | * @param string $indexName the table name (which may contain schema prefix) 329 | * @return string the generated table name 330 | */ 331 | public function generateIndexName($indexName) 332 | { 333 | if (!$this->useIndexPrefix) { 334 | return $indexName; 335 | } 336 | 337 | $db = $this->getDbConnection(); 338 | if (preg_match("/^{$db->tablePrefix}(.*?)$/", $indexName, $matches)) { 339 | $indexName = '{{%' . $matches[1] . '}}'; 340 | } elseif (preg_match("/^(.*?){$db->tablePrefix}$/", $indexName, $matches)) { 341 | $indexName = '{{' . $matches[1] . '%}}'; 342 | } 343 | return $indexName; 344 | } 345 | 346 | /** 347 | * Generates a class name from the specified table name. 348 | * @param string $indexName the table name (which may contain schema prefix) 349 | * @return string the generated class name 350 | */ 351 | protected function generateClassName($indexName) 352 | { 353 | if (isset($this->_classNames[$indexName])) { 354 | return $this->_classNames[$indexName]; 355 | } 356 | 357 | if (($pos = strrpos($indexName, '.')) !== false) { 358 | $indexName = substr($indexName, $pos + 1); 359 | } 360 | 361 | $db = $this->getDbConnection(); 362 | $patterns = []; 363 | $patterns[] = "/^{$db->tablePrefix}(.*?)$/"; 364 | $patterns[] = "/^(.*?){$db->tablePrefix}$/"; 365 | if (strpos($this->indexName, '*') !== false) { 366 | $pattern = $this->indexName; 367 | if (($pos = strrpos($pattern, '.')) !== false) { 368 | $pattern = substr($pattern, $pos + 1); 369 | } 370 | $patterns[] = '/^' . str_replace('*', '(\w+)', $pattern) . '$/'; 371 | } 372 | $className = $indexName; 373 | foreach ($patterns as $pattern) { 374 | if (preg_match($pattern, $indexName, $matches)) { 375 | $className = $matches[1]; 376 | break; 377 | } 378 | } 379 | 380 | return $this->_classNames[$indexName] = Inflector::id2camel($className, '_'); 381 | } 382 | 383 | /** 384 | * @return Connection the Sphinx connection as specified by [[db]]. 385 | */ 386 | protected function getDbConnection() 387 | { 388 | return Yii::$app->get($this->db, false); 389 | } 390 | } 391 | -------------------------------------------------------------------------------- /src/gii/model/default/model.php: -------------------------------------------------------------------------------- 1 | label) */ 12 | /* @var $rules string[] list of validation rules */ 13 | 14 | echo " 16 | 17 | namespace ns ?>; 18 | 19 | use Yii; 20 | 21 | /** 22 | * This is the model class for index "". 23 | * 24 | columns as $column): ?> 25 | * @property isMva ? 'array' : $column->phpType ?> name}\n" ?> 26 | 27 | */ 28 | class extends baseClass, '\\') . "\n" ?> 29 | { 30 | /** 31 | * {@inheritdoc} 32 | */ 33 | public static function indexName() 34 | { 35 | return 'generateIndexName($indexName) ?>'; 36 | } 37 | db !== 'sphinx'): ?> 38 | 39 | /** 40 | * @return \yii\sphinx\Connection the database connection used by this AR class. 41 | */ 42 | public static function getDb() 43 | { 44 | return Yii::$app->get('db ?>'); 45 | } 46 | 47 | 48 | /** 49 | * {@inheritdoc} 50 | */ 51 | public function rules() 52 | { 53 | return []; 54 | } 55 | 56 | /** 57 | * {@inheritdoc} 58 | */ 59 | public function attributeLabels() 60 | { 61 | return [ 62 | $label): ?> 63 | " . $generator->generateString($label) . ",\n" ?> 64 | 65 | ]; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/gii/model/form.php: -------------------------------------------------------------------------------- 1 | field($generator, 'indexName'); 7 | echo $form->field($generator, 'modelClass'); 8 | echo $form->field($generator, 'ns'); 9 | echo $form->field($generator, 'baseClass'); 10 | echo $form->field($generator, 'db'); 11 | echo $form->field($generator, 'useIndexPrefix')->checkbox(); 12 | echo $form->field($generator, 'enableI18N')->checkbox(); 13 | echo $form->field($generator, 'messageCategory'); 14 | --------------------------------------------------------------------------------