Skip to content
Snippets Groups Projects
Laurent's avatar
Laurent authored
add delete by with where string

See merge request !33
f079a07b

STORM

build status coverage report

This ORM is based on Zend_Db. Goals

  • be able to write tests on models, controllers, views without database access
  • easy integration in legacy code so we can migrate progressively code and classes from hand written SQL to object-oriented API
  • automatic management of models relationship (one to one, one to many, many to many), inspired by Smalltalk SandstoneDb and Ruby On Rails ActiveRecord

Implementation

Design

The main classes are:

  • Storm_Model_Loader: cares about loading / save / deletion in/from the database
  • Storm_Model_Abstract is the base class for models and manage relationship
  • Storm_Model_Persistence_Strategy_Abstract and sublclasses: used to switch between several persistence layers. Actually Storm_Model_Persistence_Strategy_Db rely on Zend Framework Zend_Db layers. Storm_Model_Persistence_Strategy_Volatile implements an in-memory storage mainly used for unit testing

A simple persistent model can be declared like:

class Newsletter extends Storm_Model_Abstract {
  protected $_table_name = 'newsletters';
}

Primary key

By default, table primary is supposed to be an autoincrement field named 'id'.

If your primary is not named 'id' you must define $_table_primary like:

class Datas extends Storm_Model_Abstract {
  protected $_table_name = 'datas';
  protected $_table_primary = 'id_data';
}

If your primary is not autoincremented, you must set $_fixed_id as true. Suppose you want to handle a value list with a textual primary key, like in :

class Datas extends Storm_Model_Abstract {
  protected $_table_name = 'datas';
  protected $_table_primary = 'data_code';
  protected $_fixed_id = true;
}

Storm will not ask last insert id from persistence strategy upon insert but use the provided value.

$data = Datas::newInstance()
  ->setDataCode('MY_CODE')
  ->setDataValue('any value');
$data->save();
echo $data->getId(); // returns 'MY_CODE'

Storm_Model_Loader

A Storm_Model_Loader is automatically generated for the model via Storm_Model_Abstract::getLoaderFor

Storm_Model_Loader can load the instance from the database:

  • return a list of all Newsletter objects in db:
$all_newsletters = Newsletter::getLoader()->findAll();
  • return the Newsletter object with id 5 in db
$my_newsletter = Newsletter::getLoader()->find(5);

Note that if you call find with the same id twice, only one db request will be sent as every loaded object is cached in Storm_Model_Loader

As Storm_Model_Abstract implements __callStatic, you can write above lines:

$all_newsletters = Newsletter::findAll();
$my_newsletter = Newsletter::find(5);

Storm_Model_Abstract

Storm_Model_Abstract rely on magic __call to generate automatically:

  • fiels accessors on primary attributes.
  • dependent models accessor (for one/many to one/many relations).

and on Storm_Model_Loader save, load, delete instances.

For example, the db table newsletters has 3 fields id, title, content. With previous declaration, you get for free:

$animation = Newsletter::find(5);
echo $animation->getId();
echo $animation->getTitle();
echo $animation->getContent();

$animation->setContent('bla bla bla');
$animation->save();

$concerts = new Newsletter();
$conterts
  ->setTitle('Concerts')
  ->setContent('bla bla')
  ->save();

$draft = Newsletter::find(10);
$draft->delete();

Accessors are CamelCased and follow ZF conventions:

  • title table field generate accessors getTitle and setTitle
  • USER_ID table field generate accessors getUserId and setUserId

Loading instances

find

Storm_Model_Loader::find($id) always returns the instance that has the given primary key value.

findAll

Storm_Model_Loader::findAll() returns all instances

findAllBy([...])

Storm_Model_Loader::findAllBy() returns all instances matching given criterias.

Return rows where alpha_key = 'MILLENIUM' and user_id = '12':

 BlueRay::findAllBy(['alpha_key' => 'MILLENIUM',
                     'user_id' => '12']);

Return first 10 rows ordered by creation_date where note > 2 and tag is null:

Book::findAllBy(['order' => 'creation_date desc',
                 'limit' => 10,
                 'where' => 'note>2'
                 'tag' => null,
                 'limitPage' => [$page_number, $items_by_page]
                ]);

belongs_to and has_many dependencies

For example, a Newsletter has may subscriptions NewsletterSubscription. A NewsletterSubscription instance belongs to a Newsletter instance.

So we have this DSL:

  • Newsletter has_many NewsletterSubscription.
  • NewsletterSubscription belongs_to Newsletter.
class NewsletterSubscription extends Storm_Model_Abstract {
  protected $_table_name = 'newsletters_users';
  protected $_belongs_to =  ['user' => ['model' => 'Users'],
                             'newsletter' => ['model' => 'Newsletter']];
}

There can be several belongs_to associations, so it's an array.

The key newsletter is used by Storm_Model_Abstract to understand the following messages:

$subscription = NewsletterSubscription::find(2);
$newsletter = $subscription->getNewsletter();
$user = $subscription->getUser();

$animations = Newsletter::getLoader()->find(3);
$subscription
        ->setNewsletter($animations)
        ->save();

Note that the newsletter_user table must contain the fields: id, user_id, newsletter_id.

class Newsletter extends Storm_Model_Abstract {
  protected $_table_name = 'newsletters';
  protected $_has_many = ['subscriptions' => ['model' => 'NewsletterSubscription',
                                              'role' => 'newsletter',
                                              'dependents' => 'delete']],
}

Relations has_many implicitly manage collections (note that singular / plural matters):

$animations = Newsletter::find(3);

//return all instances Subscription
$all_subscriptions = $animation->getSubscriptions(); 

$animations->removeSubscription($all_subscriptions[0]);

$animations->addSubscription($another_subscription);

$animations->setSubscriptions([$subscription1, $subscription2]);

The option 'dependents' => 'delete' tells that when a newsletter object is deleted, linked NewsletterSupscription instances are automatically deleted.

Other options include:

  • referenced_in : the field name that points to the id of the aggregate
  • scope: an array to filter the results
  • order: field for ordering results

Many to many

You need to use the option 'through' :

class Newsletter extends Storm_Model_Abstract {
  protected $_table_name = 'newsletters';
  protected $_has_many = ['subscriptions' => ['model' => 'NewsletterSubscription',
                                              'role' => 'newsletter',
                                              'dependents' => 'delete'],
                          'users' => ['through' => 'subscriptions']];
}

Here, a Newsletter instance is linked to several User instances through NewsletterSubscription instances. So we can write:

$animations = Newsletter::find(3);
$jean = Users::find(12);

$animations
   ->addUser($jean)
   ->save();

NewsletterSubscription object will be automatically created.

For users:

class User extends Storm_Model_Abstract {
  protected $_table_name = 'bib_admin_users';
  protected $_table_primary = 'ID_USER';
  protected $_has_many = ['subscriptions' => ['model' => 'NewsletterSubscription',
                                              'role' => 'user',
                                              'dependents' => 'delete'),
                          'newsletters' => ['through' => 'subscriptions']];
}

Note that the unique id of an User instance is stored in 'ID_USER' field and not 'id'. That's why we have written:

protected $_table_primary = 'ID_USER';

Relation has_many options include:

  • through: this relation relies on another one
  • unique: if true, a dependent could not appear twice
  • dependents: if 'delete', then deleting me will delete dependents
  • scope: an array of field / value pair that filters data
  • limit: when specified, add a limit for fetching descendents
  • instance_of: use specified class for handling collection (see below)

Collections

Storm provides Storm_Model_Collection (as a subclass of PHP's ArrayObject) that offers a Collection API inspired by Smalltalk Collection API.

$datas = new Storm_Model_Collection(['apple', 'banana', 'pear']);

$datas->collect(function($e) {return strlen($e);});
// answers a collection with 5,6,4

$datas->select(function($e) {return strlen($e) < 6;});
// answers a collection with apple and pear

$datas->reject(function($e) {return strlen($e) < 6;});
// answers a collection with banana

echo $datas->detect(function($e) {return strlen($e) > 5;});
// will output 'banana'


$datas->eachDo(function($e) {echo $e."\n";});
//output apple, banana and pear on each line.

Storm_Model_Collection collect, select and reject answer a new Storm_Model_Collection, thus it is possible to chain calls:

(new Storm_Model_Collection(['apple', 'banana', 'pear']))
  ->select(function($e) {return strlen($e) < 6;})
  ->collect(function($e) {return strtoupper($e);})
  ->eachDo(function($e) {echo $e."\n";});

// will output:
// APPLE
// PEAR

Has many relationship with first-class collection

Use instance_of attributes to use a first-class collection in a has_many relationship.

class Person extends Storm_Model_Abstract {
  protected $_has_many = ['cats' => ['model' => 'Cat',
                                     'instance_of' => 'Pets']];
}

class Cat extends Storm_Model_Abstract {
  protected $_belongs_to = ['master' => ['model' => 'Person']];
}

class Storm_Test_VolatilePets extends Storm_Model_Collection_Abstract {
  public function getNames() {
    return $this->collect(function($c) {return $c->getName();});
  }
}


Person::find(2)
  ->getCats()
  ->getNames()
  ->eachDo(function($s) {echo "$s\n";});

Note that collect also accept a model attribute name. So instead of passing a closure like:

$pets->collect(function($c) {return $c->getName();});

we can also write:

$pets->collect('name');

Dynamic relationships

Model relations can also be defined overriding Storm_Model_Abstract::describeAssociationsOn() :

public function describeAssociationsOn($associations) {
  $associations
    ->add(new Storm_Model_Association_HasOne('brain', ['model' => 'Storm_Test_VolatileBrain',
                                                       'referenced_in' => 'brain_id']))
}

This allow using any object extending Storm_Model_Association_Abstract as a relation between the model and virtually anything.

Association object must define canHandle, perform, save and delete.

  • canHandle($method) : should return true if the association wants to handle the method
  • perform($model, $method, $args) : should do the real job
  • save($model) : will be called when model containing the association is saved
  • delete($model) : will be called when model containing the association is deleted

Make a tree

class Profil extends Storm_Model_Abstract {  
  protected $_table_name = 'bib_admin_profil';
  protected $_table_primary = 'ID_PROFIL';

  protected $_belongs_to = ['parent_profil' => ['model' => 'Profil',
                                                'referenced_in' => 'parent_id']];

  protected $_has_many  = ['sub_profils' => ['model' => 'Profil',
                                             'role' => 'parent']];
}

Default attribute values

Use field $_default_attribute_values to specify default attribute values:

class User extends Storm_Model_Abstract {  
  protected $_table_name = 'users';
  protected $_default_attribute_values = ['login' => 'foo',
                                          'password' => 'secret'];
}

$foo = new User();
echo $foo->getLogin();
=> 'foo'

echo $foo->getPassword();
=> 'secret'

echo $foo->getName();
=> PHP Warning:  Uncaught exception 'Storm_Model_Exception' with message 'Tried to call unknown method User::getName'

Model validation

A Storm_Model_Abstract subclass can override validate to check its data before validation. If an error is reported, no data will be sent to database

class User extends Storm_Model_Abstract {  
  protected $_table_name = 'users';
  protected $_default_attribute_values = ['login' => '',
                                          'password' => ''];
  
  public function validate() {
    $this->check(!empty($this->getLogin()), 
                 'Login should not be empty !');

    $this->check(strlen($this->getPassword()) > 7, 
                 'Password should be at least 8 characters long');
  }
}

$foo = new User();
$foo
  ->setPassword('secrect')
  ->save();
echo implode("\n", $foo->getErrors());

=> Login should not be empty !
   Password should be at least 8 characters long

Hooks

Hooks can be executed before and after saving, before and after delete

class User extends Storm_Model_Abstract {  
  public function beforeSave() {
    echo "before save\n";
  }

  public function afterSave() {
    echo "after save, my id is: ".$this->getId()."\n";
  }

  public function beforeDelete() {
    echo "before delete\n";
  }

  public function afterDelete() {
    echo "after delete\n";
  }
}

User::beVolatile();
$foo = new User();
$foo->save();

=> before save
   after save, my id is: 1

$foo->delete();
=> before delete
   after delete

Testing

Mock database using Storm volatile persistence Strategy

The easiest way to write unit tests on data is to use Storm_Model_Abstract::fixture (actually implemented in trait Storm_Test_THelpers). This method swith on Volatile persistency, thus no database queries will be made, all happen in memory only for this model. Storm has been built on legacy code that required a real database to be set, so you have to tell for each model that you want to use the volatile persistence strategy.

A real example worth a thousand words:

class Storm_Test_VolatileUser extends Storm_Model_Abstract {
  protected $_table_primary = 'id_user';
} 

class Storm_Test_LoaderVolatileTest extends Storm_Test_ModelTestCase {
  protected $_loader; 

  public function setUp() {
    parent::setUp();

    $this->albert = $this->fixture('Storm_Test_VolatileUser',
                                   ['id' => 1,
                                    'login' => 'albert']);

    $this->hubert = $this->fixture('Storm_Test_VolatileUser',
                                   ['id' => 2, 
                                    'login' => 'hubert',
                                    'level' => 'invite',
                                    'option' => 'set']);

    $this->zoe = $this->fixture('Storm_Test_VolatileUser',
                                ['id' => 3, 
                                 'login' => 'zoe',
                                 'level' => 'admin']);      

    $this->max = Storm_Test_VolatileUser::newInstance(['login' => 'max',
                                                       'level' => 'invite']);
  }

  /** @test */
  public function findAllWithNewInstanceWithIdShouldReturnAllUsers() {
    $this->assertEquals([ $this->albert,$this->hubert, $this->zoe], 
                        Storm_Test_VolatileUser::findAll());
  }


  /** @test */
  public function findId2ShouldReturnHubert() {
    $this->assertEquals($this->hubert, 
                        Storm_Test_VolatileUser::find(2));
  }


  /** @test */
  public function findId5ShouldReturnNull() {
    $this->assertEquals(null, 
                        Storm_Test_VolatileUser::find(5));
  }


  /** @test */
  public function maxSaveShouldSetId() {
    $this->max->save();
    $this->assertEquals(4,$this->max->getId());
  }


  /** @test */
  public function findAllWithNewInstanceAndSaveShouldReturnAllUsers() {
    $this->max->save();
    $this->assertEquals([ $this->albert,$this->hubert, $this->zoe,$this->max], 
                        Storm_Test_VolatileUser::findAll());
  }


  /** @test */
  public function findAllInviteShouldReturnMaxEtHubert() {
    $this->max->save();
    $this->assertEquals([ $this->hubert, $this->max], 
                        Storm_Test_VolatileUser::findAll(['level'=> 'invite']));
  }


  /** @test */
  public function findAllInviteWithOptionSetShouldReturnHubert() {
    $this->max->save();

    $all_users = Storm_Test_VolatileUser::findAll(['level'=> 'invite',
                                                   'option' => 'set']);
    $this->assertEquals([$this->hubert], $all_users);
  }


  /** @test */
  public function findAllWithLoginHubertAndAlbertSetShouldReturnAlbertAndHubert() {
    $this->assertEquals([$this->albert, $this->hubert], 
                        Storm_Test_VolatileUser::findAll(['login'=> ['hubert', 'albert']]));
  }


  /**  @test */
  public function findAllInviteOrderByLoginNameDescShouldReturnMaxEtHubert() {
    $this->max->save();
    $this->assertEquals([ $this->max,$this->hubert], 
                        Storm_Test_VolatileUser::findAll(['level'=> 'invite',
                                                          'order' => 'login desc']));
  }


  /**  @test */
  public function findAllOrderByLevelShouldReturnZoeFirst() {
    $this->assertEquals([$this->albert, $this->zoe, $this->hubert], 
                        Storm_Test_VolatileUser::findAll(['order' => 'level']));
  }


  /** @test */
  public function deleteHubertFindAllShouldReturnAlbertEtZoe(){
    $this->hubert->delete();
    $this->assertEquals([ $this->albert,$this->zoe], 
                          Storm_Test_VolatileUser::findAll());
  }


  /** @test */
  public function deleteHubertFindShouldReturnNull() {
    $this->hubert->delete();
    $this->assertNull(Storm_Test_VolatileUser::find($this->hubert->getId()));
  }
  

  /** @test */
  public function countAllShouldReturn3() {
    $this->assertEquals(3, Storm_Test_VolatileUser::count());
  }


  /** @test */
  public function countByInviteShouldReturn2() {
    $this->max->save();
    $this->assertEquals(2, Storm_Test_VolatileUser::countBy(['level' => 'invite']));
  }


  /** @test */
  public function limitOneShouldReturnOne() {
    $this->assertEquals(1, count(Storm_Test_VolatileUser::findAllBy(['limit' => 1])));
  }


  /** @test */
  public function limitOneTwoShouldReturnTwo() {
   $this->assertEquals(2, count(Storm_Test_VolatileUser::findAllBy(['limit' => '1, 2'])));
  }


  /** @test */
  public function deleteByLevelInviteShouldDeleteHubertAndMax() {
    Storm_Test_VolatileUser::deleteBy(['level' => 'invite']);
    $this->assertEquals(2, count(Storm_Test_VolatileUser::findAll()));
  }


  /** @test */
  public function deleteByLevelInviteShouldRemoveHubertFromCache() {
    Storm_Test_VolatileUser::deleteBy(['level' => 'invite']);
    $this->assertNull(Storm_Test_VolatileUser::find($this->hubert->getId()));
   }


  /** @test */
  public function savingAndLoadingFromPersistenceShouldSetId() {
    Storm_Test_VolatileUser::clearCache();
    $hubert = Storm_Test_VolatileUser::find(2);
    $this->assertEquals(2, $hubert->getId());
  }
}

Mocking objects, the Storm way

Storm's ObjectWrapper is used for partial mocking (by default). Imagine you have this class:

class Foo {
  public function doRealStuff() { 
    return 'some real stuff';
  }
  
  public function doSomethingWith($bar1, $bar2) { 
    return 'executed with '.$bar1.' and '.$bar2;
  }
}

$wrapper = Storm_Test_ObjectWrapper::on(new Foo());

echo $wrapper->doRealStuff();
=> 'some real stuff'

echo $wrapper->doSomethingWith('tom', 'jerry');
=> 'executed with tom and jerry'

We can tell the wrapper to intercept a method call and return something else:

$wrapper
  ->whenCalled('doRealStuff')
  ->answers('mocked !');
echo $wrapper->doRealStuff();
=> 'mocked !'

Cool. But we can also tell to intercept a call only for given parameters:

$wrapper
  ->whenCalled('doSomethingWith')
  ->with('itchy', 'scratchy')
  ->answers('mocked for itchy and strachy !');

echo $wrapper->doSomethingWith('itchy', 'scratchy');
=> 'mocked for itchy and strachy !'

echo $wrapper->doSomethingWith('tom', 'jerry');
=> 'executed with tom and jerry'

😎 Let's got further. Sometimes we just want a fallback:

$wrapper
  ->whenCalled('doSomethingWith')
  ->with('wallace', 'gromit')
  ->answers('mocked for wallace and gromit !')

  ->whenCalled('doSomethingWith')
  ->answers('fallback mocking');

echo $wrapper->doSomethingWith('wallace', 'gromit');
=> 'mocked for wallace and gromit !'

echo $wrapper->doSomethingWith('tom', 'jerry');
=> 'fallback mocking'

We can also inject closures:

$wrapper
  ->whenCalled('doSomethingWith')
  ->willDo(function($bar1, $bar2) {
             echo 'I got '.bar1.' and '.$bar2;
           });

echo $wrapper->doSomethingWith('asterix', 'obelix');
=> 'I got asterix and obelix'

Finally, we can tell the wrapper to raise an error on unexpected calls:

$wrapper->beStrict();

echo $wrapper->doSomethingWith('wallace', 'gromit');
=> 'mocked for wallace and gromit !'

echo $wrapper->doSomethingWith('romeo', 'juliet');
=>
PHP Warning:  Uncaught exception 'Storm_Test_ObjectWrapperException' with message 'Cannot find redirection for Foo::doSomethingWith(array(2) {
  [0] =>
  string(5) "romeo"
  [1] =>
  string(6) "juliet"
}