Auto-update denormalized attributes with MongoDb and Yii2

The scenario

You have different collections in MongoDb with de-normalized attributes. As you may know, due to its not-join nature, MongoDb tends to encourage repetition of the same value in different collections (de-normalization). In this MongoDb is opposed to a more SQL-like approach where you usually reference only the ID of the value with a foreign key.

In SQL-like DBs

For example this scenario applies to a SQL-like approach:


- id: ```int(11)```

- username: ```varchar(128)```

- address_id: ```int(11)```

where the ```address_id``` field is a foreign id to:

```tbl_address```

- id: ```int(11)``` 

- street: ```varchar(255)```

- city_id: ```int(11)```

where the ```city_id``` field is a foreign key to the ```tbl_city``` etc...

### In MongoDb

In Mongo it's a whole different story and, depending on your requirements, you may want to follow this approach:

collection ```user```

~~~
[JavaScript]

{
    _id:12345
    username:"peterGower",
    address:{
        city:"someCity",
        street:"someStreet"
    }
}

~~~

and, if you want to check that the field "city" is always the same to avoid "sameCity" "SameCity" or "SAMECITY" to be considered different, you may want to create a collection ```location.city``` that you'll check for existence before inserting the "city" attribute in the ```user``` collection.

collection ```location.city```

~~~
[JavaScript]

{
    _id:12345,
    name:"someCity"
}

~~~

All this is well and good, but what happens if we modify the name of a city to our referenced attributes? Well, nothing, meaning that ```city.name:"newCityName"``` will not affect ```user.address.city:"oldCityName"``` . Having a de-normalized DB without foreignKeys has its cost, but we may solve this with a simple behaviour in Yii2.

Sync referenced data with a behaviour
------------------

Before starting we may want to pay attention to one thing: this approach can become a huge bottleneck in a collection with a lot of updates on the referenced fields. If your collection's referenced attribute is subject to frequent changes, you may want to reference the foreignId (in MongoDb this approach is called _linking_)

### The Behaviour


```php
/**
 * @author edoardo849 <edoardo.s@daviom.com>
 */

namespace common\behaviors;


use yii\base\Behavior;
use yii\base\ErrorException;
use yii\db\BaseActiveRecord;
use Yii;
use yii\helpers\Json;
use yii\mongodb\file\ActiveRecord as MongoGridFsAR;

class DependenciesBehavior extends Behavior
{
	public $attributes = [];

	public $mongoTimeout = 100000000;

	public $multiple = true;

	public $errorOnDelete = true;

	public function events()
	{
		return [
			BaseActiveRecord::EVENT_BEFORE_UPDATE => 'onBeforeUpdate',
			BaseActiveRecord::EVENT_BEFORE_DELETE => 'onBeforeDelete'
		];
	}

	public function onBeforeUpdate()
	{
		$changes = $this->getChanges();

		if (!empty($changes)) {
			foreach ($changes as $change) {
				if (isset($this->attributes[$change[0]])) {
					$this->updateDependency($change[0], $change[1], $change[2]);
				}
			}
		}
	}

	private function getChanges()
	{
		$changes = [];
		foreach ($this->owner->attributes as $key => $newValue) {
			if (isset($this->owner->oldAttributes[$key])) {
				$oldValue = $this->owner->oldAttributes[$key];
				if ($oldValue != $newValue) {
					$changes[] = [$key, $oldValue, $newValue];
				}
			}
		}
		return $changes;
	}

	private function updateDependency($key, $oldValue, $newValue)
	{
		$dependencies = $this->attributes[$key];
		foreach ($dependencies as $dependency) {
			/**
			 * @var \yii\mongodb\ActiveRecord $model
			 */
			$model = new $dependency['class'];
			$targetAttribute = $dependency['targetAttribute'];
			$collectionName = $model::collectionName();
			if ($model instanceof MongoGridFsAR) {
				$collectionName = $collectionName . '.files';
			}
			/**
			 * @var \MongoCollection $mongoCommand
			 */
			$mongoCommand = Yii::$app->mongodb->getCollection($collectionName);
			$mongoCommand->update([$targetAttribute => $oldValue], ['$set' => [$targetAttribute => $newValue]], ['multiple' => $this->multiple, 'timeout' => $this->mongoTimeout]);
		}
	}

	public function onBeforeDelete()
	{
		if ($this->errorOnDelete) {
			$changes = $this->getChanges();
			if (!empty($changes)) {
				throw new ErrorException('The following attributes has dependencies and cannot be deleted: ' . Json::encode($changes), 500);
			}
		}

	}

} 

```

### The Model

In the model that has references (so, following our previous example, we may want to include this behaviour in the ```location.city``` model, which is ```\common\models\LocationCity.php``` in our case) 


```php
public function behaviors()
    {
        return [
			'dependencies'=>[
				'class'=>DependenciesBehavior::className(),
				'attributes'=>[
					'name'=>[
						['class'=>User::className(), 'targetAttribute'=>'address.city'],
                                                ['class'=>Company::className(), 'targetAttribute'=>'registeredOffice.city'],
					]
				]
			]
        ];
    }


```

Here we say: the attribute ```location.city.name``` has references in 2 collections: ```user.address.city``` and ```company.registeredOffice.city``` . So, every time you modify the "name" attribute, update the dependencies.

### How the behaviour works

In order not to waste precious memory and CPU time, the behaviour will not do anything unless the specified value has changed. For example, if we perform an update in the field ```location.city.shortName```, the behaviour will recognise that the attribute ```location.city.name``` is unchanged and will do nothing.

On the contrary, when we do change ```location.city.name``` the behaviour will perform a multi-update of all the specified references.

In addition, it will throw an error if you try to delete an attribute that has references in other collections (like the ```ON UPDATE RESTRICT``` of MySql).

There are some configuration options that you may want to modify:

- ```$mongoTimeout```: integer, for large collections, it's useful to extend the standard timeout of the PHP's MongoCursor (30 sec)

- ```$multiple```: boolean, whether to update all the referenced attributes or not (default:true)

- ```$errorOnDelete```: boolean, throws an error if you try to delete referenced attributes (default:false)