patbator/storm

Simple Active Record ORM with easy testing capabilities

1.0.1 2021-01-28 19:39 UTC

This package is auto-updated.

Last update: 2024-05-29 02:04:18 UTC


README

pipeline 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'

:sunglasses: 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"
}