STORM
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
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"
}