patbator/storm

简单活跃记录ORM,具有易于测试的功能

1.0.1 2021-01-28 19:39 UTC

This package is auto-updated.

Last update: 2024-09-29 02:44:40 UTC


README

pipeline status coverage report

此ORM基于Zend_Db。目标

  • 能够在没有数据库访问的情况下编写模型、控制器、视图的测试
  • 易于集成到旧代码中,因此我们可以逐步迁移代码和类,从手写SQL到面向对象的API
  • 自动管理模型关系(一对一、一对多、多对多),灵感来自Smalltalk SandstoneDb和Ruby On Rails ActiveRecord

实现

Design

主要类包括

  • Storm_Model_Loader: 负责数据库中的加载/保存/删除
  • Storm_Model_Abstract是模型的基础类,负责管理关系
  • Storm_Model_Persistence_Strategy_Abstract及其子类:用于在多个持久化层之间切换。实际上,Storm_Model_Persistence_Strategy_Db依赖于Zend Framework的Zend_Db层。Storm_Model_Persistence_Strategy_Volatile实现了一个内存存储,主要用于单元测试

一个简单的持久化模型可以声明如下

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

主键

默认情况下,表的主键假定是一个名为'id'的自增字段。

如果你的主键不是名为'id',你必须定义$_table_primary,如下所示

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

如果你的主键不是自增的,你必须设置$_fixed_id为true。假设你想处理一个带有文本主键的值列表,如下所示

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

Storm在插入时不会从持久化策略中请求最后插入的ID,而是使用提供的值。

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

Storm_Model_Loader

Storm_Model_Loader通过Storm_Model_Abstract::getLoaderFor自动为模型生成

Storm_Model_Loader可以从数据库加载实例

  • 返回数据库中所有Newsletter对象的列表

    $all_newsletters = Newsletter::getLoader()->findAll();
    
  • 返回数据库中ID为5的Newsletter对象

    $my_newsletter = Newsletter::getLoader()->find(5);
    

注意,如果你两次用相同的id调用find,只会发送一个数据库请求,因为每个加载的对象都缓存在Storm_Model_Loader中

由于Storm_Model_Abstract实现了__callStatic,你可以编写上面的行

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

Storm_Model_Abstract

Storm_Model_Abstract依赖于魔法__call来自动生成

  • 主要属性的访问器。
  • 从属模型访问器(用于一对一/多对一/多对多关系)。

以及在Storm_Model_Loader中保存、加载、删除实例。

例如,数据库表newsletters有3个字段id、title、content。使用前面的声明,你将免费获得

$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();

访问器采用驼峰命名法,遵循ZF约定

  • title表字段生成访问器getTitlesetTitle
  • USER_ID表字段生成访问器getUserIdsetUserId

加载实例

find

Storm_Model_Loader::find($id)始终返回具有给定主键值的实例。

findAll

Storm_Model_Loader::findAll()返回所有实例

findAllBy([...])

Storm_Model_Loader::findAllBy()返回所有匹配给定标准的实例。

返回alpha_key = 'MILLENIUM'且user_id = '12'的行

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

返回创建日期大于2且tag为null的前10行

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

belongs_to和has_many依赖关系

例如,Newsletter可能有多个订阅NewsletterSubscription。NewsletterSubscription实例属于Newsletter实例。

因此我们有以下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']];
}

可以有多个belongs_to关联,因此它是一个数组。

newsletter用于Storm_Model_Abstract理解以下消息

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

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

注意,newsletter_user表必须包含字段: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']],
}

has_many关系隐式管理集合(注意单数/复数很重要)

$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]);

选项 'dependents' => 'delete' 表示,当删除一个新闻简报对象时,与之关联的 NewsletterSubscription 实例将被自动删除。

其他选项包括:

  • referenced_in:指向聚合 id 的字段名
  • scope:一个用于过滤结果的数组
  • order:用于排序结果的字段

多对多

需要使用 'through' 选项

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

在此,新闻简报实例通过 NewsletterSubscription 实例与多个用户实例相关联。因此,我们可以编写:

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

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

NewsletterSubscription 对象将被自动创建。

对于用户:

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']];
}

请注意,用户实例的唯一 id 存储在 'ID_USER' 字段中,而不是 'id' 字段中。这就是为什么我们编写了:

protected $_table_primary = 'ID_USER';

多对多关系选项包括:

  • through:此关系依赖于另一个关系
  • unique:如果为 true,则依赖项不能出现两次
  • dependents:如果为 'delete',则删除我将会删除依赖项
  • scope:一个字段/值对的数组,用于过滤数据
  • limit:指定时,为获取后代添加限制
  • instance_of:使用指定的类处理集合(见下文)

集合

Storm 提供了 Storm_Model_Collection(作为 PHP 的 ArrayObject 的子类),它提供了一个受 Smalltalk 集合 API 启发的集合 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 的 collectselectreject 方法返回一个新的 Storm_Model_Collection,因此可以进行链式调用

(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

与一等集合的多对多关系

使用 instance_of 属性在多对多关系中使用一等集合。

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";});

请注意,collect 也接受模型属性名称。因此,我们可以这样写,而不是传递一个闭包:

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

我们也可以这样写:

$pets->collect('name');

动态关系

可以通过重写 Storm_Model_Abstract::describeAssociationsOn() 来定义模型关系

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

这允许使用任何扩展 Storm_Model_Association_Abstract 的对象作为模型与几乎任何东西之间的关系。

关联对象必须定义 canHandle、perform、save 和 delete。

  • canHandle($method) : 如果关联想处理该方法,则应返回 true
  • perform($model, $method, $args) : 应执行实际工作
  • save($model) : 当包含关联的模型被保存时将被调用
  • delete($model) : 当包含关联的模型被删除时将被调用

创建树

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 来指定默认属性值

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'

模型验证

Storm_Model_Abstract 子类可以重写 validate 来在验证之前检查其数据。如果报告了错误,则不会将数据发送到数据库

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

钩子

钩子可以在保存前和保存后、删除前和删除后执行

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

测试

使用 Storm 的 Volatile 持久性策略模拟数据库

编写数据单元测试的最简单方法是使用 Storm_Model_Abstract::fixture(实际上在 Storm_Test_THelpers 特性中实现)。此方法将打开 Volatile 持久性,因此不会进行数据库查询,所有操作都仅在内存中发生 仅为此模型。由于 Storm 是建立在需要设置真实数据库的旧代码之上,因此您必须为每个模型指定您想使用 Volatile 持久性策略。

一个值得千言万语的实例

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());
  }
}

模拟对象,Storm 的方式

Storm 的 ObjectWrapper 用于 部分模拟(默认)。想象一下您有一个这样的类:

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'

我们可以告诉包装器拦截一个方法调用并返回其他内容

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

酷。但我们也可以告诉只拦截具有给定参数的调用

$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: 让我们更进一步。有时我们只想有一个后备

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

我们还可以注入闭包

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

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

最后,我们可以告诉包装器在意外调用时引发错误

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