patbator / storm
Requires (Dev)
- phpunit/phpunit: ~9
This package is auto-updated.
Last update: 2024-09-29 02:44:40 UTC
README
此ORM基于Zend_Db。目标
- 能够在没有数据库访问的情况下编写模型、控制器、视图的测试
- 易于集成到旧代码中,因此我们可以逐步迁移代码和类,从手写SQL到面向对象的API
- 自动管理模型关系(一对一、一对多、多对多),灵感来自Smalltalk SandstoneDb和Ruby On Rails ActiveRecord
实现
主要类包括
- 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表字段生成访问器getTitle和setTitle
- USER_ID表字段生成访问器getUserId和setUserId
加载实例
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 的 collect、select 和 reject 方法返回一个新的 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"
}