Skip to content

Commit

Permalink
Various Improvements, mainly around hooks (#65)
Browse files Browse the repository at this point in the history
* Added afterUpdateQuery / afterInsertQuery

* Implement and document how to prevent default operations on active record

* remove outdated test (since it's no longer on readme)

* Added some really nice advanced topics.

* allow hasOne()->addField('foo'); (instead of saying foo twice)

* Applied fixes from StyleCI

* typos

* typo
  • Loading branch information
romaninsh authored and DarkSide666 committed Jul 21, 2016
1 parent 1b9c538 commit 93e5d66
Show file tree
Hide file tree
Showing 5 changed files with 437 additions and 72 deletions.
278 changes: 278 additions & 0 deletions docs/advanced.rst
@@ -0,0 +1,278 @@

===============
Advanced Topics
===============

Agile Data allow you to implement various tricks.


Audit Fields
============

If you wish to have a certain field inside your models that will be automatically changed
when the record is being updated, this can be easily implemented in Agile Data.

I will be looking to create the following fields:

- created_dts
- updated_dts
- created_by_user_id
- updated_by_user_id

To implement the above, I'll create a new class::

class Controller_Audit {
use \atk4\core\InitializerTrait {
init as _init;
}
use \atk4\core\TrackableTrait;
use \atk4\core\AppScopeTrait;

}

TrackableTrait means that I'll be able to add this object inside model
with ``$model->add(new Controller_Audit())`` and that will automatically
populate $owner, and $app values (due to AppScopeTrait) as well as
execute init() method, which I want to define like this::


public function init() {
$this->_init();

if(isset($this->owner->no_audit)){
return;
}

$this->owner->addField('created_dts', ['type'=>'datetime', 'default'=>date('Y-m-d H:i:s')]);

$this->owner->hasOne('created_by_user_id', 'User');
if(isset($this->app->user) and $this->app->user->loaded()) {
$this->owner->getElement('created_by_user_id')->default = $this->app->user->id;
}

$this->owner->hasOne('updated_by_user_id', 'User');

$this->owner->addField('updated_dts', ['type'=>'datetime']);

$this->owner->addHook('beforeUpdate', function($m, $data) {
if(isset($this->app->user) and $this->app->user->loaded()) {
$data['updated_by'] = $this->app->user->id;
}
$data['updated_dts'] = date('Y-m-d H:i:s');
});
}

In order to add your defined behaviour to the model. The first check actually allows you to define
models that will bypass audit alltogether::

$u1 = new Model_User($db); // Model_User::init() includes audit

$u2 = new Model_User($db, ['no_audit' => true]); // will exclude audit features

Next we are going to define 'created_dts' field which will default to the current date and time.

The default value for our 'created_by_user_id' field would depend on a currently-logged in user,
which would typically be accessible through your application. AppScope allows you to pass
$app around through all the objects, which means that your Audit Controller will be able
to get the current user.

Of course if the application is not defined, no default is set. This would be handy for
unit tests where you could manually specify the value for this field.

The last 2 fields (update_*) will be updated through a hook - beforeUpdate() and will
provide the values to be saved during ``save()``. beforeUpdate() will not be called when
new record is inserted, so those fields will be left as "null" after initial insert.

If you wish, you can modify the code and insert historical records into other table.

Soft Delete
===========

Most of the data frameworks provide some way to enable 'soft-delete' for tables as
a core feature. Design of Agile Data makes it possible to implement soft-delete
through external controller. There may be a 3rd party controller for comprehensive
soft-delete, but in this section I'll explain how you can easily build your own
soft-delete controller for Agile Data (for educational purposes).

Start by creating a class::

class Controller_SoftDelete {
use \atk4\core\InitializerTrait {
init as _init;
}
use \atk4\core\TrackableTrait;

function init() {
$this->_init();

if(isset($this->owner->no_soft_delete)){
return;
}

$this->owner->addField('is_deleted', ['type'=>'boolean']);

if (isset($this->owner->deleted_only)) {
$this->owner->addCondition('is_deleted', true);
$this->owner->addMethod('restore', $this);
}else{
$this->owner->addCondition('is_deleted', false);
$this->owner->addMethod('softDelete', $this);
}
}

function softDelete($m) {
if (!$m->loaded()) {
throw new \atk4\core\Exception(['Model must be loaded before soft-deleting', 'model'=>$m]);
}

$id = $m->id;
if ($m->hook('beforeSoftDelete') === false) {
return $m;
}

$rs = $m->reload_after_save;
$m->reload_after_save = false;
$m->save(['is_deleted'=>true])->unload();
$m->reload_after_save = $rs;

$m->hook('afterSoftDelete', [$id]);
return $m;
}

function restore($m) {
if (!$m->loaded()) {
throw new \atk4\core\Exception(['Model must be loaded before restoring', 'model'=>$m]);
}

$id = $m->id;
if ($m->hook('beforeRestore') === false) {
return $m;
}

$rs = $m->reload_after_save;
$m->reload_after_save = false;
$m->save(['is_deleted'=>false])->unload();
$m->reload_after_save = $rs;

$m->hook('afterRestore', [$id]);
return $m;
}
}

This implementation of soft-delete can be turned off by setting model's property 'deleted_only'
to true (if you want to recover a record).

When active, a new field will be defined 'is_deleted' and a new dynamic method will be added into
a model, allowing you to do this::

$m = new Model_Invoice($db);
$m->load(10);
$m->softDelete();

The method body is actually defined in our controller. Notice that we have defined 2
hooks - beforeSoftDelete and afterSoftDelete that work similarly to beforeDelete and afterDelete.

beforeSoftDelete will allow you to "break" it in certain cases to bypass the rest of method, again,
this is to maintain conistency with the rest of before* hooks in Agile Data.

Hooks are called through the model, so your call-back will autamtically receive first argument
$m, and afterSoftDelete will pass second argument - $id of deleted record.

I am then setting reload_after_save value to false, because after I set 'is_deleted' to false,
$m will no longer be able to load the record - it will fall outside of the DataSet. (We
might implement a better method for saving records outside of DataSet in the future).

After softDelete active record is unloaded, mimicking behaviour of delete().

It's also possible for you to easily look at deleted records and even restore them::

$m = new Model_Invoice($db, ['deleted_only'=>true]);
$m->load(10);
$m->restore();

Note that you can call $m->delete() still on any record to permanently delete it.

Soft Delete that overrides default delete()
-------------------------------------------

In case you want $m->delete() to perform soft-delete for you - this can also be achieved through
a pretty simple controller. In fact I'm reusing the one from before and just slightly modifying
it::

class Controller_SoftDelete {
use \atk4\core\InitializerTrait {
init as _init;
}
use \atk4\core\TrackableTrait;

function init() {
$this->_init();

if(isset($this->owner->no_soft_delete)){
return;
}

$this->owner->addField('is_deleted', ['type'=>'boolean']);

if (isset($this->owner->deleted_only)) {
$this->owner->addCondition('is_deleted', true);
$this->owner->addMethod('restore', $this);
} else {
$this->owner->addCondition('is_deleted', false);
$this->owner->addHook('beforeDelete', [$this, 'softDelete'], null, 100);
}
}

function softDelete($m) {
if (!$m->loaded()) {
throw new \atk4\core\Exception(['Model must be loaded before soft-deleting', 'model'=>$m]);
}

$id = $m->id;

$rs = $m->reload_after_save;
$m->reload_after_save = false;
$m->save(['is_deleted'=>true])->unload();
$m->reload_after_save = $rs;

$m->hook('afterDelete', [$id]);

$m->breakHook(false); // this will cancel original delete()
}

function restore($m) {
if (!$m->loaded()) {
throw new \atk4\core\Exception(['Model must be loaded before restoring', 'model'=>$m]);
}

$id = $m->id;
if ($m->hook('beforeRestore') === false) {
return $m;
}

$rs = $m->reload_after_save;
$m->reload_after_save = false;
$m->save(['is_deleted'=>false])->unload();
$m->reload_after_save = $rs;

$m->hook('afterRestore', [$id]);
return $m;
}
}

Implementation of this controller is similar to the one above, however instead of creating softDelete()
it overrides the delete() method through a hook. It will still call 'afterDelete' to mimic the
behaviour of regular delete() after the record is marked as deleted and unloaded.

You can still access the deleted records::

$m = new Model_Invoice($db, ['deleted_only'=>true]);
$m->load(10);
$m->restore();

Calling delete() on the model with 'deleted_only' property will delete it permanently.


60 changes: 56 additions & 4 deletions docs/model.rst
Expand Up @@ -216,17 +216,69 @@ Full example::
Hooks
=====

- beforeSave [not currently worknig]
- beforeSave [not currently working]

- beforeInsert [only if insert]
- beforeInsertQuery [sql only]
- beforeInsertQuery [sql only] (query)
- afterInsertQuery (query, statement)

- beforeUpdate [only ift update]
- beforeUpdateQuery [sql only]

- beforeUpdateQuery [sql only] (query)
- afterUpdateQuery (query, statement)


- afterUpdate [only if existing record]
- afterInsert [only if new record]

- afterSave

How to verify Updates
---------------------

The model is only being saved if any fields have been changed (dirty).
Sometimes it's possible that the record in the database is no longer
available and your update() may not actually update anything. This
does not normally generate an error, however if you want to actually
make sure that update() was effective, you can implement this through
a hook::

$m->addHook('afterUpdateQuery',function($m, $update, $st) {
if (!$st->rowCount()) {
throw new \atk4\core\Exception([
'Update didn\'t affect any records',
'query' => $update->getDebugQuery(false),
'statement' => $st,
'model' => $m,
'conditions' => $m->conditions,
]);
}
});


How to prevent actions
----------------------

In some cases you want to prevent default actions from executing.
Suppose you want to check 'memcache' before actually loading the
record from the database. Here is how you can implement this
functionality::

$m->addHook('beforeLoad',function($m, $id) {
$data = $m->app->cacheFetch($m->table, $id);
if ($data) {
$m->data = $data;
$m->id = $id;
$m->breakHook(false);
}
});

$app property is injected through your $db object and is passed
around to all the models. This hook, if successful, will prevent
further execution of other beforeLoad hooks and by specifying
argument as 'false' it will also prevent call to $persistence
for actual loading of the data.

Similarly you can prevent deletion if you wish to implement
:ref:`soft-delete` or stop insert/modify from occuring.


12 changes: 10 additions & 2 deletions src/Model.php
Expand Up @@ -564,6 +564,10 @@ public function load($id)
$this->unload();
}

if ($this->hook('beforeLoad', [$id]) === false) {
return $this;
}

$this->data = $this->persistence->load($this, $id);
$this->id = $id;
$this->hook('afterLoad');
Expand Down Expand Up @@ -739,7 +743,9 @@ public function save($data = [])
}
}

$this->hook('beforeInsert', [&$data]);
if ($this->hook('beforeInsert', [&$data]) === false) {
return $this;
}

// Collect all data of a new record
$this->id = $this->persistence->insert($this, $data);
Expand Down Expand Up @@ -854,7 +860,9 @@ public function delete($id = null)

return $this;
} elseif ($this->loaded()) {
$this->hook('beforeDelete', [$this->id]);
if ($this->hook('beforeDelete', [$this->id]) === false) {
return $this;
}
$this->persistence->delete($this, $this->id);
$this->hook('afterDelete', [$this->id]);
$this->unload();
Expand Down

0 comments on commit 93e5d66

Please sign in to comment.