ActiveRecord.php 18.2 KB
Newer Older
1 2 3
<?php
/**
 * @link http://www.yiiframework.com/
4
 * @copyright Copyright (c) 2008 Yii Software LLC
5 6 7 8 9 10 11
 * @license http://www.yiiframework.com/license/
 */

namespace yii\elasticsearch;

use yii\base\InvalidCallException;
use yii\base\InvalidConfigException;
12
use yii\db\BaseActiveRecord;
13 14
use yii\helpers\Inflector;
use yii\helpers\Json;
15
use yii\helpers\StringHelper;
16 17 18 19

/**
 * ActiveRecord is the base class for classes representing relational data in terms of objects.
 *
Carsten Brandt committed
20 21 22 23 24 25
 * This class implements the ActiveRecord pattern for the fulltext search and data storage
 * [elasticsearch](http://www.elasticsearch.org/).
 *
 * For defining a record a subclass should at least implement the [[attributes()]] method to define
 * attributes.
 * The primary key (the `_id` field in elasticsearch terms) is represented by `getId()` and `setId()`.
26
 * The primary key is not part of the attributes.
Carsten Brandt committed
27 28 29 30 31 32 33 34 35 36 37 38
 *
 * The following is an example model called `Customer`:
 *
 * ```php
 * class Customer extends \yii\elasticsearch\ActiveRecord
 * {
 *     public function attributes()
 *     {
 *         return ['id', 'name', 'address', 'registration_date'];
 *     }
 * }
 * ```
39
 *
Carsten Brandt committed
40
 * You may override [[index()]] and [[type()]] to define the index and type this record represents.
41
 *
42 43 44
 * @property float $score Returns the score of this record when it was retrieved via a [[find()]] query. This
 * property is read-only.
 *
45 46 47
 * @author Carsten Brandt <mail@cebe.cc>
 * @since 2.0
 */
48
class ActiveRecord extends BaseActiveRecord
49
{
50
	private $_id;
51
	private $_score;
52 53
	private $_version;

54 55 56 57 58 59 60 61
	/**
	 * Returns the database connection used by this AR class.
	 * By default, the "elasticsearch" application component is used as the database connection.
	 * You may override this method if you want to use a different database connection.
	 * @return Connection the database connection used by this AR class.
	 */
	public static function getDb()
	{
62
		return \Yii::$app->getComponent('elasticsearch');
63 64 65
	}

	/**
Qiang Xue committed
66
	 * @inheritdoc
67 68 69 70 71
	 */
	public static function find($q = null)
	{
		$query = static::createQuery();
		if (is_array($q)) {
72
			return $query->andWhere($q)->one();
73 74 75 76 77 78
		} elseif ($q !== null) {
			return static::get($q);
		}
		return $query;
	}

79 80 81 82 83 84 85 86 87 88
	/**
	 * Gets a record by its primary key.
	 *
	 * @param mixed $primaryKey the primaryKey value
	 * @param array $options options given in this parameter are passed to elasticsearch
	 * as request URI parameters.
	 * Please refer to the [elasticsearch documentation](http://www.elasticsearch.org/guide/en/elasticsearch/reference/current/docs-get.html)
	 * for more details on these options.
	 * @return static|null The record instance or null if it was not found.
	 */
89 90
	public static function get($primaryKey, $options = [])
	{
91 92 93
		if ($primaryKey === null) {
			return null;
		}
94 95 96
		$command = static::getDb()->createCommand();
		$result = $command->get(static::index(), static::type(), $primaryKey, $options);
		if ($result['exists']) {
97 98
			$model = static::instantiate($result);
			static::populateRecord($model, $result);
Carsten Brandt committed
99 100
			$model->afterFind();
			return $model;
101 102 103 104
		}
		return null;
	}

105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124
	/**
	 * Gets a list of records by its primary keys.
	 *
	 * @param array $primaryKeys an array of primaryKey values
	 * @param array $options options given in this parameter are passed to elasticsearch
	 * as request URI parameters.
	 *
	 * Please refer to the [elasticsearch documentation](http://www.elasticsearch.org/guide/en/elasticsearch/reference/current/docs-get.html)
	 * for more details on these options.
	 * @return static|null The record instance or null if it was not found.
	 */

	public static function mget($primaryKeys, $options = [])
	{
		if (empty($primaryKeys)) {
			return [];
		}
		$command = static::getDb()->createCommand();
		$result = $command->mget(static::index(), static::type(), $primaryKeys, $options);
		$models = [];
AlexGx committed
125
		foreach ($result['docs'] as $doc) {
126
			if ($doc['exists']) {
127 128
				$model = static::instantiate($doc);
				static::populateRecord($model, $doc);
Carsten Brandt committed
129 130
				$model->afterFind();
				$models[] = $model;
131 132 133 134 135
			}
		}
		return $models;
	}

136 137 138 139
	// TODO add more like this feature http://www.elasticsearch.org/guide/en/elasticsearch/reference/current/search-more-like-this.html

	// TODO add percolate functionality http://www.elasticsearch.org/guide/en/elasticsearch/reference/current/search-percolate.html

140
	/**
141 142 143 144 145 146 147 148 149 150
	 * Creates an [[ActiveQuery]] instance.
	 *
	 * This method is called by [[find()]], [[findBySql()]] to start a SELECT query but also
	 * by [[hasOne()]] and [[hasMany()]] to create a relational query.
	 * You may override this method to return a customized query (e.g. `CustomerQuery` specified
	 * written for querying `Customer` purpose.)
	 *
	 * You may also define default conditions that should apply to all queries unless overridden:
	 *
	 * ```php
151
	 * public static function createQuery($config = [])
152
	 * {
153
	 *     return parent::createQuery($config)->where(['deleted' => false]);
154 155 156 157 158 159
	 * }
	 * ```
	 *
	 * Note that all queries should use [[Query::andWhere()]] and [[Query::orWhere()]] to keep the
	 * default condition. Using [[Query::where()]] will override the default condition.
	 *
160
	 * @param array $config the configuration passed to the ActiveQuery class.
161
	 * @return ActiveQuery the newly created [[ActiveQuery]] instance.
162
	 */
163
	public static function createQuery($config = [])
164
	{
165 166
		$config['modelClass'] = get_called_class();
		return new ActiveQuery($config);
167 168 169
	}

	// TODO implement copy and move as pk change is not possible
170

171 172 173 174
	/**
	 * @return float returns the score of this record when it was retrieved via a [[find()]] query.
	 */
	public function getScore()
175
	{
176
		return $this->_score;
177 178
	}

179
	/**
180 181 182 183
	 * Sets the primary key
	 * @param mixed $value
	 * @throws \yii\base\InvalidCallException when record is not new
	 */
184
	public function setPrimaryKey($value)
185
	{
186
		$pk = static::primaryKey()[0];
187 188
		if ($this->getIsNewRecord() || $pk != '_id') {
			$this->$pk = $value;
189 190 191 192 193 194
		} else {
			throw new InvalidCallException('Changing the primaryKey of an already saved record is not allowed.');
		}
	}

	/**
Qiang Xue committed
195
	 * @inheritdoc
196 197 198
	 */
	public function getPrimaryKey($asArray = false)
	{
199
		$pk = static::primaryKey()[0];
200
		if ($asArray) {
201
			return [$pk => $this->$pk];
202
		} else {
203
			return $this->$pk;
204 205 206 207
		}
	}

	/**
Qiang Xue committed
208
	 * @inheritdoc
209 210 211
	 */
	public function getOldPrimaryKey($asArray = false)
	{
212
		$pk = static::primaryKey()[0];
213 214 215 216 217 218 219
		if ($this->getIsNewRecord()) {
			$id = null;
		} elseif ($pk == '_id') {
			$id = $this->_id;
		} else {
			$id = $this->getOldAttribute($pk);
		}
220
		if ($asArray) {
221
			return [$pk => $id];
222
		} else {
223
			return $id;
224
		}
225 226 227
	}

	/**
228
	 * This method defines the attribute that uniquely identifies a record.
229
	 *
230 231
	 * The primaryKey for elasticsearch documents is the `_id` field by default. This field is not part of the
	 * ActiveRecord attributes so you should never add `_id` to the list of [[attributes()|attributes]].
232
	 *
233 234 235 236 237 238 239 240 241
	 * You may overide this method to define the primary key name when you have defined
	 * [path mapping](http://www.elasticsearch.org/guide/en/elasticsearch/reference/current/mapping-id-field.html)
	 * for the `_id` field so that it is part of the `_source` and thus part of the [[attributes()|attributes]].
	 *
	 * Note that elasticsearch only supports _one_ attribute to be the primary key. However to match the signature
	 * of the [[\yii\db\ActiveRecordInterface|ActiveRecordInterface]] this methods returns an array instead of a
	 * single string.
	 *
	 * @return string[] array of primary key attributes. Only the first element of the array will be used.
242 243 244
	 */
	public static function primaryKey()
	{
245
		return ['_id'];
246 247 248 249
	}

	/**
	 * Returns the list of all attribute names of the model.
250
	 *
251
	 * This method must be overridden by child classes to define available attributes.
252 253 254 255 256 257 258
	 *
	 * Attributes are names of fields of the corresponding elasticsearch document.
	 * The primaryKey for elasticsearch documents is the `_id` field by default which is not part of the attributes.
	 * You may define [path mapping](http://www.elasticsearch.org/guide/en/elasticsearch/reference/current/mapping-id-field.html)
	 * for the `_id` field so that it is part of the `_source` fields and thus becomes part of the attributes.
	 *
	 * @return string[] list of attribute names.
259
	 */
260
	public function attributes()
261 262 263 264
	{
		throw new InvalidConfigException('The attributes() method of elasticsearch ActiveRecord has to be implemented by child classes.');
	}

265 266 267
	/**
	 * @return string the name of the index this record is stored in.
	 */
268 269
	public static function index()
	{
270
		return Inflector::pluralize(Inflector::camel2id(StringHelper::basename(get_called_class()), '-'));
271 272
	}

273 274 275
	/**
	 * @return string the name of the type of this record.
	 */
276 277
	public static function type()
	{
278
		return Inflector::camel2id(StringHelper::basename(get_called_class()), '-');
279 280 281
	}

	/**
282
	 * @inheritdoc
283
	 */
284
	public static function populateRecord($record, $row)
285
	{
286
		parent::populateRecord($record, $row['_source']);
287
		$pk = static::primaryKey()[0];
288
		if ($pk === '_id') {
289
			$record->_id = $row['_id'];
290
		}
291 292
		$record->_score = isset($row['_score']) ? $row['_score'] : null;
		$record->_version = isset($row['_version']) ? $row['_version'] : null; // TODO version should always be available...
293 294 295 296 297 298
	}

	/**
	 * Creates an active record instance.
	 *
	 * This method is called together with [[populateRecord()]] by [[ActiveQuery]].
299
	 * It is not meant to be used for creating new records directly.
300 301 302 303 304 305 306 307 308 309 310 311 312 313 314
	 *
	 * You may override this method if the instance being created
	 * depends on the row data to be populated into the record.
	 * For example, by creating a record based on the value of a column,
	 * you may implement the so-called single-table inheritance mapping.
	 * @param array $row row data to be populated into the record.
	 * This array consists of the following keys:
	 * - `_source`: refers to the attributes of the record.
	 * - `_type`: the type this record is stored in.
	 * - `_index`: the index this record is stored in.
	 * @return static the newly created active record
	 */
	public static function instantiate($row)
	{
		return new static;
315 316 317
	}

	/**
318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365
	 * Inserts a document into the associated index using the attribute values of this record.
	 *
	 * This method performs the following steps in order:
	 *
	 * 1. call [[beforeValidate()]] when `$runValidation` is true. If validation
	 *    fails, it will skip the rest of the steps;
	 * 2. call [[afterValidate()]] when `$runValidation` is true.
	 * 3. call [[beforeSave()]]. If the method returns false, it will skip the
	 *    rest of the steps;
	 * 4. insert the record into database. If this fails, it will skip the rest of the steps;
	 * 5. call [[afterSave()]];
	 *
	 * In the above step 1, 2, 3 and 5, events [[EVENT_BEFORE_VALIDATE]],
	 * [[EVENT_BEFORE_INSERT]], [[EVENT_AFTER_INSERT]] and [[EVENT_AFTER_VALIDATE]]
	 * will be raised by the corresponding methods.
	 *
	 * Only the [[dirtyAttributes|changed attribute values]] will be inserted into database.
	 *
	 * If the [[primaryKey|primary key]] is not set (null) during insertion,
	 * it will be populated with a
	 * [randomly generated value](http://www.elasticsearch.org/guide/en/elasticsearch/reference/current/docs-index_.html#_automatic_id_generation)
	 * after insertion.
	 *
	 * For example, to insert a customer record:
	 *
	 * ~~~
	 * $customer = new Customer;
	 * $customer->name = $name;
	 * $customer->email = $email;
	 * $customer->insert();
	 * ~~~
	 *
	 * @param boolean $runValidation whether to perform validation before saving the record.
	 * If the validation fails, the record will not be inserted into the database.
	 * @param array $attributes list of attributes that need to be saved. Defaults to null,
	 * meaning all attributes will be saved.
	 * @param array $options options given in this parameter are passed to elasticsearch
	 * as request URI parameters. These are among others:
	 *
	 * - `routing` define shard placement of this record.
	 * - `parent` by giving the primaryKey of another record this defines a parent-child relation
	 * - `timestamp` specifies the timestamp to store along with the document. Default is indexing time.
	 *
	 * Please refer to the [elasticsearch documentation](http://www.elasticsearch.org/guide/en/elasticsearch/reference/current/docs-index_.html)
	 * for more details on these options.
	 *
	 * By default the `op_type` is set to `create`.
	 * @return boolean whether the attributes are valid and the record is inserted successfully.
366
	 */
367
	public function insert($runValidation = true, $attributes = null, $options = ['op_type' => 'create'])
368 369 370 371 372 373 374 375 376 377 378
	{
		if ($runValidation && !$this->validate($attributes)) {
			return false;
		}
		if ($this->beforeSave(true)) {
			$values = $this->getDirtyAttributes($attributes);

			$response = static::getDb()->createCommand()->insert(
				static::index(),
				static::type(),
				$values,
379 380
				$this->getPrimaryKey(),
				$options
381 382
			);

383
			if (!isset($response['ok'])) {
384 385
				return false;
			}
386 387 388 389 390
			$pk = static::primaryKey()[0];
			$this->$pk = $response['_id'];
			if ($pk != '_id') {
				$values[$pk] = $response['_id'];
			}
391
			$this->_version = $response['_version'];
392
			$this->_score = null;
393 394 395 396 397 398 399 400 401
			$this->setOldAttributes($values);
			$this->afterSave(true);
			return true;
		}
		return false;
	}

	/**
	 * Updates all records whos primary keys are given.
402 403 404
	 * For example, to change the status to be 1 for all customers whose status is 2:
	 *
	 * ~~~
Luciano Baraglia committed
405
	 * Customer::updateAll(['status' => 1], [2, 3, 4]);
406 407 408 409 410 411 412
	 * ~~~
	 *
	 * @param array $attributes attribute values (name-value pairs) to be saved into the table
	 * @param array $condition the conditions that will be put in the WHERE part of the UPDATE SQL.
	 * Please refer to [[ActiveQuery::where()]] on how to specify this parameter.
	 * @return integer the number of rows updated
	 */
413
	public static function updateAll($attributes, $condition = [])
414
	{
415
		$pkName = static::primaryKey()[0];
416
		if (count($condition) == 1 && isset($condition[$pkName])) {
417
			$primaryKeys = is_array($condition[$pkName]) ? $condition[$pkName] : [$condition[$pkName]];
418
		} else {
419
			$primaryKeys = static::find()->where($condition)->column($pkName); // TODO check whether this works with default pk _id
420 421
		}
		if (empty($primaryKeys)) {
422 423
			return 0;
		}
424
		$bulk = '';
AlexGx committed
425
		foreach ($primaryKeys as $pk) {
426 427
			$action = Json::encode([
				"update" => [
428
					"_id" => $pk,
429 430 431 432
					"_type" => static::type(),
					"_index" => static::index(),
				],
			]);
433
			$data = Json::encode([
434
				"doc" => $attributes
435
			]);
436
			$bulk .= $action . "\n" . $data . "\n";
437
		}
438 439

		// TODO do this via command
440 441
		$url = [static::index(), static::type(), '_bulk'];
		$response = static::getDb()->post($url, [], $bulk);
442
		$n=0;
443
		$errors = [];
AlexGx committed
444
		foreach ($response['items'] as $item) {
445 446 447
			if (isset($item['update']['error'])) {
				$errors[] = $item['update'];
			} elseif ($item['update']['ok']) {
448
				$n++;
449 450
			}
		}
451 452 453
		if (!empty($errors)) {
			throw new Exception(__METHOD__ . ' failed updating records.', $errors);
		}
454 455 456
		return $n;
	}

457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472
	/**
	 * Updates all matching records using the provided counter changes and conditions.
	 * For example, to increment all customers' age by 1,
	 *
	 * ~~~
	 * Customer::updateAllCounters(['age' => 1]);
	 * ~~~
	 *
	 * @param array $counters the counters to be updated (attribute name => increment value).
	 * Use negative values if you want to decrement the counters.
	 * @param string|array $condition the conditions that will be put in the WHERE part of the UPDATE SQL.
	 * Please refer to [[Query::where()]] on how to specify this parameter.
	 * @return integer the number of rows updated
	 */
	public static function updateAllCounters($counters, $condition = [])
	{
473
		$pkName = static::primaryKey()[0];
474
		if (count($condition) == 1 && isset($condition[$pkName])) {
475
			$primaryKeys = is_array($condition[$pkName]) ? $condition[$pkName] : [$condition[$pkName]];
476
		} else {
477
			$primaryKeys = static::find()->where($condition)->column($pkName); // TODO check whether this works with default pk _id
478 479 480 481 482
		}
		if (empty($primaryKeys) || empty($counters)) {
			return 0;
		}
		$bulk = '';
AlexGx committed
483
		foreach ($primaryKeys as $pk) {
484 485 486 487 488 489 490 491
			$action = Json::encode([
				"update" => [
					"_id" => $pk,
					"_type" => static::type(),
					"_index" => static::index(),
				],
			]);
			$script = '';
AlexGx committed
492
			foreach ($counters as $counter => $value) {
493 494 495 496
				$script .= "ctx._source.$counter += $counter;\n";
			}
			$data = Json::encode([
				"script" => $script,
AlexGx committed
497
				"params" => $counters
498 499 500 501 502 503 504 505
			]);
			$bulk .= $action . "\n" . $data . "\n";
		}

		// TODO do this via command
		$url = [static::index(), static::type(), '_bulk'];
		$response = static::getDb()->post($url, [], $bulk);
		$n=0;
506
		$errors = [];
AlexGx committed
507
		foreach ($response['items'] as $item) {
508 509 510
			if (isset($item['update']['error'])) {
				$errors[] = $item['update'];
			} elseif ($item['update']['ok']) {
511 512 513
				$n++;
			}
		}
514 515 516
		if (!empty($errors)) {
			throw new Exception(__METHOD__ . ' failed updating records counters.', $errors);
		}
517 518
		return $n;
	}
519 520 521 522 523 524 525 526 527 528 529 530 531 532 533

	/**
	 * Deletes rows in the table using the provided conditions.
	 * WARNING: If you do not specify any condition, this method will delete ALL rows in the table.
	 *
	 * For example, to delete all customers whose status is 3:
	 *
	 * ~~~
	 * Customer::deleteAll('status = 3');
	 * ~~~
	 *
	 * @param array $condition the conditions that will be put in the WHERE part of the DELETE SQL.
	 * Please refer to [[ActiveQuery::where()]] on how to specify this parameter.
	 * @return integer the number of rows deleted
	 */
534
	public static function deleteAll($condition = [])
535
	{
536
		$pkName = static::primaryKey()[0];
537
		if (count($condition) == 1 && isset($condition[$pkName])) {
538
			$primaryKeys = is_array($condition[$pkName]) ? $condition[$pkName] : [$condition[$pkName]];
539
		} else {
540
			$primaryKeys = static::find()->where($condition)->column($pkName); // TODO check whether this works with default pk _id
541 542
		}
		if (empty($primaryKeys)) {
543 544
			return 0;
		}
545
		$bulk = '';
AlexGx committed
546
		foreach ($primaryKeys as $pk) {
547
			$bulk .= Json::encode([
548
				"delete" => [
549
					"_id" => $pk,
550 551 552 553
					"_type" => static::type(),
					"_index" => static::index(),
				],
			]) . "\n";
554
		}
555 556

		// TODO do this via command
557 558
		$url = [static::index(), static::type(), '_bulk'];
		$response = static::getDb()->post($url, [], $bulk);
559
		$n=0;
560
		$errors = [];
AlexGx committed
561
		foreach ($response['items'] as $item) {
562 563 564
			if (isset($item['delete']['error'])) {
				$errors[] = $item['delete'];
			} elseif ($item['delete']['found'] && $item['delete']['ok']) {
565 566
				$n++;
			}
567
		}
568 569 570
		if (!empty($errors)) {
			throw new Exception(__METHOD__ . ' failed deleting records.', $errors);
		}
571
		return $n;
572 573
	}
}