├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── composer.json ├── phpunit.xml.dist ├── src ├── Feature.php ├── Rollout.php ├── RolloutUserInterface.php └── Storage │ ├── ArrayStorage.php │ ├── DoctrineCacheStorageAdapter.php │ ├── MongoDBStorageAdapter.php │ ├── PDOStorageAdapter.php │ ├── RedisStorageAdapter.php │ └── StorageInterface.php └── tests ├── FeatureTest.php ├── RolloutTest.php └── Storage ├── MongoDBStorageAdapterTest.php ├── PDOStorageAdapterTest.php └── RedisStorageAdapterTest.php /.gitignore: -------------------------------------------------------------------------------- 1 | composer.lock 2 | phpunit.xml 3 | /vendor/ 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | services: 3 | - mongodb 4 | php: 5 | - 5.6 6 | - 7.0 7 | - 7.1 8 | - 7.2 9 | - nightly 10 | - hhvm 11 | 12 | matrix: 13 | fast_finish: true 14 | allow_failures: 15 | - php: nightly 16 | - php: hhvm 17 | before_script: echo "extension = mongo.so" >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini 18 | install: composer install -n 19 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2.0.0 2 | ----- 3 | 4 | (BC Break) The `StorageInterface` interface had a `remove` function added to its definition. If you have your own implementation of this interface, you'll need to update your implementation to include this functionality. 5 | 6 | - Added support for removing a feature 7 | 8 | 1.0.4 9 | ----- 10 | 11 | - Added redis support [PR #9](https://github.com/opensoft/rollout/pull/9) 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2009 James Golick 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | rollout (for php) 2 | ================= 3 | 4 | [![Build Status](https://travis-ci.org/opensoft/rollout.svg?branch=master)](https://travis-ci.org/opensoft/rollout) [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/opensoft/rollout/badges/quality-score.png?s=a75edbc812e0b27279496e8f2f274f6a4c58dd9a)](https://scrutinizer-ci.com/g/opensoft/rollout/) [![Code Coverage](https://scrutinizer-ci.com/g/opensoft/rollout/badges/coverage.png?s=f2e7939ee89b8788df83bcc556aefedcf03cb6e4)](https://scrutinizer-ci.com/g/opensoft/rollout/) 5 | 6 | Feature flippers for PHP. A port of ruby's [rollout](https://github.com/FetLife/rollout). 7 | 8 | Install It 9 | ---------- 10 | 11 | composer require opensoft/rollout 12 | 13 | How it works 14 | ------------ 15 | 16 | Initialize a rollout object: 17 | 18 | ```php 19 | use Opensoft\Rollout\Rollout; 20 | use Opensoft\Rollout\Storage\ArrayStorage; 21 | 22 | $rollout = new Rollout(new ArrayStorage()); 23 | ``` 24 | 25 | Check if a feature is active for a particular user: 26 | 27 | ```php 28 | $rollout->isActive('chat', $user); // returns true/false 29 | ``` 30 | 31 | Check if a feature is activated globally: 32 | 33 | ```php 34 | $rollout->isActive('chat'); // returns true/false 35 | ``` 36 | 37 | Storage 38 | ------- 39 | 40 | There are a number of different storage implementations for where the configuration for the rollout is stored. 41 | 42 | * ArrayStorage - default storage, not persistent 43 | * DoctrineCacheStorageAdapter - requires [doctrine/cache][doctrine-cache] 44 | * PDOStorageAdapter - persistent using [PDO][pdo] 45 | * RedisStorageAdapter - persistent using [Redis][redis] 46 | * MongoDBStorageAdapter - persistent using [Mongo][mongo] 47 | 48 | [doctrine-cache]: https://packagist.org/packages/doctrine/cache 49 | [pdo]: http://php.net/pdo 50 | [redis]: http://redis.io 51 | [mongo]: http://mongodb.org 52 | 53 | All storage adapters must implement `Opensoft\Rollout\Storage\StorageInterface`. 54 | 55 | Groups 56 | ------ 57 | 58 | Rollout ships with one group by default: `all`, which does exactly what it sounds like. 59 | 60 | You can activate the `all` group for chat features like this: 61 | 62 | ```php 63 | $rollout->activateGroup('chat', 'all'); 64 | ``` 65 | 66 | You may also want to define your own groups. We have one for caretakers: 67 | 68 | ```php 69 | $rollout->defineGroup('caretakers', function(RolloutUserInterface $user = null) { 70 | if (null === $user) { 71 | return false; 72 | } 73 | 74 | return $user->isCaretaker(); // boolean 75 | }); 76 | ``` 77 | 78 | You can activate multiple groups per feature. 79 | 80 | Deactivate groups like this: 81 | 82 | ```php 83 | $rollout->deactivateGroup('chat'); 84 | ``` 85 | 86 | Specific Users 87 | -------------- 88 | 89 | You may want to let a specific user into a beta test or something. If that user isn't part of an existing group, you can let them in specifically: 90 | 91 | ```php 92 | $rollout->activateUser('chat', $user); 93 | ``` 94 | 95 | Deactivate them like this: 96 | 97 | ```php 98 | $rollout->deactivateUser('chat', $user); 99 | ``` 100 | 101 | Rollout users must implement the `RolloutUserInterface`. 102 | 103 | User Percentages 104 | ---------------- 105 | 106 | If you're rolling out a new feature, you may want to test the waters by slowly enabling it for a percentage of your users. 107 | 108 | ```php 109 | $rollout->activatePercentage('chat', 20); 110 | ``` 111 | 112 | The algorithm for determining which users get let in is this: 113 | 114 | ```php 115 | crc32($user->getRolloutIdentifier()) % 100 < $percentage 116 | ``` 117 | 118 | So, for 20%, users 0, 1, 10, 11, 20, 21, etc would be allowed in. Those users would remain in as the percentage increases. 119 | 120 | Deactivate all percentages like this: 121 | 122 | ```php 123 | $rollout->deactivatePercentage('chat'); 124 | ``` 125 | 126 | **Note:** Activating a feature for 100% of users will also make it activate `globally`. This is like calling `$rollout->isActive()` without a user object. 127 | 128 | Feature is Broken 129 | ----------------- 130 | 131 | Deactivate everybody at once: 132 | 133 | ```php 134 | $rollout->deactivate('chat'); 135 | ``` 136 | 137 | You may wish to disable features programmatically if monitoring tools detect unusually high error rates for example. 138 | 139 | Remove a Feature (added in 2.0.0) 140 | --------------------------------- 141 | 142 | After a feature becomes mainstream or a failed experiment, you may want to remove the feature definition from rollout. 143 | 144 | ```php 145 | $rollout->remove('chat'); 146 | ``` 147 | 148 | Note: If there is still code referencing the feature, it will be recreated with default settings. 149 | 150 | Symfony2 Bundle 151 | --------------- 152 | 153 | A Symfony2 bundle is available to integrate rollout into Symfony2 projects. It can be found at http://github.com/opensoft/OpensoftRolloutBundle. 154 | 155 | Zend Framework 2 Module 156 | ----------------------- 157 | 158 | A Zend Framework 2 module is availabile to intergrate rollout into Zend Framwork 2 projects. It can be found at https://github.com/adlogix/zf2-opensoft-rollout. 159 | 160 | Implementations in other languages 161 | ---------------------------------- 162 | 163 | * Ruby: http://github.com/FetLife/rollout 164 | * Python: http://github.com/asenchi/proclaim 165 | 166 | Copyright 167 | --------- 168 | 169 | Copyright © 2017 James Golick, BitLove, Inc. See LICENSE for details. 170 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "opensoft/rollout", 3 | "description": "Feature switches or flags for PHP", 4 | "keywords": ["feature", "flag", "toggle", "rollout", "flipper", "switches"], 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Richard Fullmer", 9 | "email": "richard.fullmer@opensoftdev.com" 10 | } 11 | ], 12 | "require": { 13 | "php": ">=5.3.3" 14 | }, 15 | "suggest": { 16 | "doctrine/cache": "For use with the DoctrineCacheStorageAdapter", 17 | "predis/predis": "For use with the RedisStorageAdapter" 18 | }, 19 | "require-dev": { 20 | "doctrine/cache": "*", 21 | "phpunit/phpunit": ">=4.0,<6.0" 22 | }, 23 | "autoload": { 24 | "psr-4": { 25 | "Opensoft\\Rollout\\": "src/" 26 | } 27 | }, 28 | "minimum-stability": "dev" 29 | } 30 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 15 | 16 | 17 | 18 | tests 19 | 20 | 21 | 22 | 23 | 24 | src/ 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /src/Feature.php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | class Feature 12 | { 13 | /** 14 | * @var string 15 | */ 16 | private $name; 17 | 18 | /** 19 | * @var array 20 | */ 21 | private $groups = array(); 22 | 23 | /** 24 | * @var array 25 | */ 26 | private $users = array(); 27 | 28 | /** 29 | * @var integer 30 | */ 31 | private $percentage = 0; 32 | 33 | /** 34 | * @var string|null 35 | */ 36 | private $requestParam; 37 | 38 | /** 39 | * @var array 40 | */ 41 | private $data = array(); 42 | 43 | /** 44 | * @param string $name 45 | * @param string|null $settings 46 | */ 47 | public function __construct($name, $settings = null) 48 | { 49 | $this->name = $name; 50 | if ($settings) { 51 | $settings = explode('|', $settings); 52 | 53 | if (isset($settings[3])) { 54 | $rawRequestParam = $settings[3]; 55 | $this->requestParam = $rawRequestParam; 56 | } 57 | 58 | //We can not trust the list function because of backwords compatibility 59 | if (isset($settings[4])) { 60 | $rawData = $settings[4]; 61 | $this->data = !empty($rawData)? json_decode($rawData, true) : array(); 62 | } 63 | 64 | list($rawPercentage, $rawUsers, $rawGroups) = $settings; 65 | $this->percentage = (int) $rawPercentage; 66 | $this->users = !empty($rawUsers) ? explode(',', $rawUsers) : array(); 67 | $this->groups = !empty($rawGroups) ? explode(',', $rawGroups) : array(); 68 | } else { 69 | $this->clear(); 70 | } 71 | } 72 | 73 | /** 74 | * @return string 75 | */ 76 | public function getName() 77 | { 78 | return $this->name; 79 | } 80 | 81 | /** 82 | * @param integer $percentage 83 | */ 84 | public function setPercentage($percentage) 85 | { 86 | $this->percentage = $percentage; 87 | } 88 | 89 | /** 90 | * @return integer 91 | */ 92 | public function getPercentage() 93 | { 94 | return $this->percentage; 95 | } 96 | 97 | /** 98 | * @return string 99 | */ 100 | public function serialize() 101 | { 102 | return implode('|', array( 103 | $this->percentage, 104 | implode(',', $this->users), 105 | implode(',', $this->groups), 106 | $this->requestParam, 107 | json_encode($this->data) 108 | )); 109 | } 110 | 111 | /** 112 | * @param RolloutUserInterface $user 113 | */ 114 | public function addUser(RolloutUserInterface $user) 115 | { 116 | if (!in_array($user->getRolloutIdentifier(), $this->users)) { 117 | $this->users[] = $user->getRolloutIdentifier(); 118 | } 119 | } 120 | 121 | /** 122 | * @param RolloutUserInterface $user 123 | */ 124 | public function removeUser(RolloutUserInterface $user) 125 | { 126 | if (($key = array_search($user->getRolloutIdentifier(), $this->users)) !== false) { 127 | unset($this->users[$key]); 128 | } 129 | } 130 | 131 | /** 132 | * @return array 133 | */ 134 | public function getUsers() 135 | { 136 | return $this->users; 137 | } 138 | 139 | /** 140 | * @param string $group 141 | */ 142 | public function addGroup($group) 143 | { 144 | if (!in_array($group, $this->groups)) { 145 | $this->groups[] = $group; 146 | } 147 | } 148 | 149 | /** 150 | * @param string $group 151 | */ 152 | public function removeGroup($group) 153 | { 154 | if (($key = array_search($group, $this->groups)) !== false) { 155 | unset($this->groups[$key]); 156 | } 157 | } 158 | 159 | /** 160 | * @return array 161 | */ 162 | public function getGroups() 163 | { 164 | return $this->groups; 165 | } 166 | 167 | /** 168 | * @return string|null 169 | */ 170 | public function getRequestParam() 171 | { 172 | return $this->requestParam; 173 | } 174 | 175 | /** 176 | * @param string|null $requestParam 177 | */ 178 | public function setRequestParam($requestParam) 179 | { 180 | $this->requestParam = $requestParam; 181 | } 182 | 183 | /** 184 | * @return array 185 | */ 186 | public function getData() 187 | { 188 | return $this->data; 189 | } 190 | 191 | /** 192 | * @param array $data 193 | */ 194 | public function setData(array $data) 195 | { 196 | $this->data = $data; 197 | } 198 | 199 | /** 200 | * Clear the feature of all configuration 201 | */ 202 | public function clear() 203 | { 204 | $this->groups = array(); 205 | $this->users = array(); 206 | $this->percentage = 0; 207 | $this->requestParam = ''; 208 | $this->data = array(); 209 | } 210 | 211 | /** 212 | * Is the feature active? 213 | * 214 | * @param Rollout $rollout 215 | * @param RolloutUserInterface|null $user 216 | * @param array $requestParameters 217 | * @return bool 218 | */ 219 | public function isActive(Rollout $rollout, RolloutUserInterface $user = null, array $requestParameters = array()) 220 | { 221 | if (null == $user) { 222 | return $this->isParamInRequestParams($requestParameters) 223 | || $this->percentage == 100 224 | || $this->isInActiveGroup($rollout); 225 | } 226 | 227 | return $this->isParamInRequestParams($requestParameters) || 228 | $this->isUserInPercentage($user) || 229 | $this->isUserInActiveUsers($user) || 230 | $this->isInActiveGroup($rollout, $user); 231 | } 232 | 233 | /** 234 | * @return array 235 | */ 236 | public function toArray() 237 | { 238 | return array( 239 | 'percentage' => $this->percentage, 240 | 'groups' => $this->groups, 241 | 'users' => $this->users, 242 | 'requestParam' => $this->requestParam, 243 | 'data'=> $this->data 244 | ); 245 | } 246 | 247 | /** 248 | * @param array $requestParameters 249 | * @return bool 250 | */ 251 | private function isParamInRequestParams(array $requestParameters) 252 | { 253 | $param = explode('=', $this->requestParam); 254 | $key = array_shift($param); 255 | $value = array_shift($param); 256 | 257 | return $key && array_key_exists($key, $requestParameters) && 258 | (empty($value) || $requestParameters[$key] == $value); 259 | } 260 | 261 | /** 262 | * @param RolloutUserInterface $user 263 | * @return bool 264 | */ 265 | private function isUserInPercentage(RolloutUserInterface $user) 266 | { 267 | return abs(crc32($user->getRolloutIdentifier()) % 100) < $this->percentage; 268 | } 269 | 270 | /** 271 | * @param RolloutUserInterface $user 272 | * @return boolean 273 | */ 274 | private function isUserInActiveUsers(RolloutUserInterface $user) 275 | { 276 | return in_array($user->getRolloutIdentifier(), $this->users); 277 | } 278 | 279 | /** 280 | * @param Rollout $rollout 281 | * @param RolloutUserInterface|null $user 282 | * @return bool 283 | */ 284 | private function isInActiveGroup(Rollout $rollout, RolloutUserInterface $user = null) 285 | { 286 | foreach ($this->groups as $group) { 287 | if ($rollout->isActiveInGroup($group, $user)) { 288 | return true; 289 | } 290 | } 291 | 292 | return false; 293 | } 294 | } 295 | -------------------------------------------------------------------------------- /src/Rollout.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | class Rollout 14 | { 15 | /** 16 | * @var StorageInterface 17 | */ 18 | private $storage; 19 | 20 | /** 21 | * @var array 22 | */ 23 | private $groups; 24 | 25 | /** 26 | * @param StorageInterface $storage 27 | */ 28 | public function __construct(StorageInterface $storage) 29 | { 30 | $this->storage = $storage; 31 | $this->groups = array( 32 | 'all' => function(RolloutUserInterface $user) { return $user !== null; } 33 | ); 34 | } 35 | 36 | /** 37 | * @param string $feature 38 | */ 39 | public function activate($feature) 40 | { 41 | $feature = $this->get($feature); 42 | if ($feature) { 43 | $feature->setPercentage(100); 44 | $this->save($feature); 45 | } 46 | } 47 | 48 | /** 49 | * @param string $feature 50 | */ 51 | public function deactivate($feature) 52 | { 53 | $feature = $this->get($feature); 54 | if ($feature) { 55 | $feature->clear(); 56 | $this->save($feature); 57 | } 58 | } 59 | 60 | /** 61 | * @param string $feature 62 | * @param string $group 63 | */ 64 | public function activateGroup($feature, $group) 65 | { 66 | $feature = $this->get($feature); 67 | if ($feature) { 68 | $feature->addGroup($group); 69 | $this->save($feature); 70 | } 71 | } 72 | 73 | /** 74 | * @param string $feature 75 | * @param string $group 76 | */ 77 | public function deactivateGroup($feature, $group) 78 | { 79 | $feature = $this->get($feature); 80 | if ($feature) { 81 | $feature->removeGroup($group); 82 | $this->save($feature); 83 | } 84 | } 85 | 86 | /** 87 | * @param string $feature 88 | * @param RolloutUserInterface $user 89 | */ 90 | public function activateUser($feature, RolloutUserInterface $user) 91 | { 92 | $feature = $this->get($feature); 93 | if ($feature) { 94 | $feature->addUser($user); 95 | $this->save($feature); 96 | } 97 | } 98 | 99 | /** 100 | * @param string $feature 101 | * @param RolloutUserInterface $user 102 | */ 103 | public function deactivateUser($feature, RolloutUserInterface $user) 104 | { 105 | $feature = $this->get($feature); 106 | if ($feature) { 107 | $feature->removeUser($user); 108 | $this->save($feature); 109 | } 110 | } 111 | 112 | /** 113 | * @param string $group 114 | * @param \Closure $closure 115 | */ 116 | public function defineGroup($group, \Closure $closure) 117 | { 118 | $this->groups[$group] = $closure; 119 | } 120 | 121 | /** 122 | * @param string $feature 123 | * @param RolloutUserInterface|null $user 124 | * @param array $requestParameters 125 | * @return bool 126 | */ 127 | public function isActive($feature, RolloutUserInterface $user = null, array $requestParameters = array()) 128 | { 129 | $feature = $this->get($feature); 130 | 131 | return $feature ? $feature->isActive($this, $user, $requestParameters) : false; 132 | } 133 | 134 | /** 135 | * @param string $feature 136 | * @param integer $percentage 137 | */ 138 | public function activatePercentage($feature, $percentage) 139 | { 140 | $feature = $this->get($feature); 141 | if ($feature) { 142 | $feature->setPercentage($percentage); 143 | $this->save($feature); 144 | } 145 | } 146 | 147 | /** 148 | * @param string $feature 149 | */ 150 | public function deactivatePercentage($feature) 151 | { 152 | $feature = $this->get($feature); 153 | if ($feature) { 154 | $feature->setPercentage(0); 155 | $this->save($feature); 156 | } 157 | } 158 | 159 | /** 160 | * @param string $feature 161 | * @param string $requestParam 162 | */ 163 | public function activateRequestParam($feature, $requestParam) 164 | { 165 | $feature = $this->get($feature); 166 | if ($feature) { 167 | $feature->setRequestParam($requestParam); 168 | $this->save($feature); 169 | } 170 | } 171 | 172 | /** 173 | * @param string $feature 174 | */ 175 | public function deactivateRequestParam($feature) 176 | { 177 | $feature = $this->get($feature); 178 | if ($feature) { 179 | $feature->setRequestParam(''); 180 | $this->save($feature); 181 | } 182 | } 183 | 184 | /** 185 | * @param string $group 186 | * @param RolloutUserInterface|null $user 187 | * @return bool 188 | */ 189 | public function isActiveInGroup($group, RolloutUserInterface $user = null) 190 | { 191 | if (!isset($this->groups[$group])) { 192 | return false; 193 | } 194 | 195 | $g = $this->groups[$group]; 196 | 197 | return $g && $g($user); 198 | } 199 | 200 | /** 201 | * @param string $feature 202 | * @return Feature 203 | */ 204 | public function get($feature) 205 | { 206 | $settings = $this->storage->get($this->key($feature)); 207 | 208 | if (!empty($settings)) { 209 | $f = new Feature($feature, $settings); 210 | } else { 211 | $f = new Feature($feature); 212 | 213 | $this->save($f); 214 | } 215 | 216 | return $f; 217 | } 218 | 219 | /** 220 | * Remove a feature definition from rollout 221 | * 222 | * @param string $feature 223 | */ 224 | public function remove($feature) 225 | { 226 | $this->storage->remove($this->key($feature)); 227 | 228 | $features = $this->features(); 229 | if (in_array($feature, $features)) { 230 | $features = array_diff($features, array($feature)); 231 | } 232 | $this->storage->set($this->featuresKey(), implode(',', $features)); 233 | } 234 | 235 | /** 236 | * Update feature specific data 237 | * 238 | * @example $rollout->setFeatureData('chat', array( 239 | * 'description' => 'foo', 240 | * 'release_date' => 'bar', 241 | * 'whatever' => 'baz' 242 | * )); 243 | * 244 | * @param string $feature 245 | * @param array $data 246 | */ 247 | public function setFeatureData($feature, array $data) 248 | { 249 | $feature = $this->get($feature); 250 | if ($feature) { 251 | $feature->setData(array_merge($feature->getData(), $data)); 252 | $this->save($feature); 253 | } 254 | } 255 | 256 | /** 257 | * Clear all feature data 258 | * 259 | * @param string $feature 260 | */ 261 | public function clearFeatureData($feature) 262 | { 263 | $feature = $this->get($feature); 264 | if ($feature) { 265 | $feature->setData(array()); 266 | $this->save($feature); 267 | } 268 | } 269 | 270 | /** 271 | * @return array 272 | */ 273 | public function features() 274 | { 275 | $content = $this->storage->get($this->featuresKey()); 276 | 277 | if (!empty($content)) { 278 | return explode(',', $content); 279 | } 280 | 281 | return array(); 282 | } 283 | 284 | /** 285 | * @param string $name 286 | * @return string 287 | */ 288 | private function key($name) 289 | { 290 | return 'feature:' . $name; 291 | } 292 | 293 | /** 294 | * @return string 295 | */ 296 | private function featuresKey() 297 | { 298 | return 'feature:__features__'; 299 | } 300 | 301 | /** 302 | * @param Feature $feature 303 | */ 304 | protected function save(Feature $feature) 305 | { 306 | $name = $feature->getName(); 307 | $this->storage->set($this->key($name), $feature->serialize()); 308 | 309 | $features = $this->features(); 310 | if (!in_array($name, $features)) { 311 | $features[] = $name; 312 | } 313 | $this->storage->set($this->featuresKey(), implode(',', $features)); 314 | } 315 | } 316 | -------------------------------------------------------------------------------- /src/RolloutUserInterface.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | interface RolloutUserInterface 13 | { 14 | /** 15 | * @return string 16 | */ 17 | public function getRolloutIdentifier(); 18 | } 19 | -------------------------------------------------------------------------------- /src/Storage/ArrayStorage.php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | class ArrayStorage implements StorageInterface 12 | { 13 | /** 14 | * @var array 15 | */ 16 | private $storage = array(); 17 | 18 | /** 19 | * @param string $key 20 | * @return mixed|null 21 | */ 22 | public function get($key) 23 | { 24 | return isset($this->storage[$key]) ? $this->storage[$key] : null; 25 | } 26 | 27 | /** 28 | * @param string $key 29 | * @param mixed $value 30 | */ 31 | public function set($key, $value) 32 | { 33 | $this->storage[$key] = $value; 34 | } 35 | 36 | /** 37 | * @param string $key 38 | */ 39 | public function remove($key) 40 | { 41 | if (isset($this->storage[$key])) { 42 | unset($this->storage[$key]); 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Storage/DoctrineCacheStorageAdapter.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | class DoctrineCacheStorageAdapter implements StorageInterface 16 | { 17 | /** 18 | * @var Cache 19 | */ 20 | private $cache; 21 | 22 | /** 23 | * @param Cache $cache 24 | */ 25 | public function __construct(Cache $cache) 26 | { 27 | $this->cache = $cache; 28 | } 29 | 30 | /** 31 | * @param string $key 32 | * @return mixed|null Null if the value is not found 33 | */ 34 | public function get($key) 35 | { 36 | return $this->cache->fetch($key); 37 | } 38 | 39 | /** 40 | * @param string $key 41 | * @param mixed $value 42 | */ 43 | public function set($key, $value) 44 | { 45 | $this->cache->save($key, $value); 46 | } 47 | 48 | /** 49 | * @param string $key 50 | */ 51 | public function remove($key) 52 | { 53 | $this->cache->delete($key); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Storage/MongoDBStorageAdapter.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | class MongoDBStorageAdapter implements StorageInterface 11 | { 12 | 13 | /** 14 | * @var object 15 | */ 16 | private $mongo; 17 | 18 | /** 19 | * @var string 20 | */ 21 | private $collection; 22 | 23 | public function __construct($mongo, $collection = "rollout_feature") 24 | { 25 | $this->mongo = $mongo; 26 | $this->collection = $collection; 27 | } 28 | public function getCollectionName() 29 | { 30 | return $this->collection; 31 | } 32 | /** 33 | * @inheritdoc 34 | */ 35 | public function get($key) 36 | { 37 | $collection = $this->getCollectionName(); 38 | $result = $this->mongo->$collection->findOne(['name' => $key]); 39 | 40 | if (!$result) { 41 | return null; 42 | } 43 | 44 | return $result['value']; 45 | } 46 | 47 | /** 48 | * @inheritdoc 49 | */ 50 | public function set($key, $value) 51 | { 52 | $collection = $this->getCollectionName(); 53 | $this->mongo->$collection->update(['name' => $key], ['$set' => ['value' => $value]], ['upsert' => true]); 54 | } 55 | 56 | /** 57 | * @inheritdoc 58 | */ 59 | public function remove($key) 60 | { 61 | $collection = $this->getCollectionName(); 62 | $this->mongo->$collection->remove(['name' => $key]); 63 | } 64 | 65 | } 66 | -------------------------------------------------------------------------------- /src/Storage/PDOStorageAdapter.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | class PDOStorageAdapter implements StorageInterface 11 | { 12 | const STMT_SELECT = 'SELECT settings FROM :table WHERE name = :key'; 13 | const STMT_INSERT = 'INSERT INTO :table (name, settings) VALUES (:key, :value)'; 14 | const STMT_UPDATE = 'UPDATE :table SET settings = :value WHERE name = :key'; 15 | const STMT_DELETE = 'DELETE FROM :table WHERE name = :key'; 16 | 17 | /** 18 | * @var \PDO 19 | */ 20 | private $pdoConnection; 21 | 22 | /** 23 | * @var string 24 | */ 25 | private $tableName; 26 | 27 | public function __construct(\PDO $pdoConnection, $tableName = 'rollout_feature') 28 | { 29 | $this->pdoConnection = $pdoConnection; 30 | $this->tableName = $tableName; 31 | } 32 | 33 | /** 34 | * {@inheritdoc} 35 | */ 36 | public function get($key) 37 | { 38 | $statement = $this->pdoConnection->prepare($this->getSQLStatement(self::STMT_SELECT)); 39 | 40 | $statement->bindParam('key', $key); 41 | $statement->setFetchMode(\PDO::FETCH_ASSOC); 42 | 43 | $statement->execute(); 44 | 45 | $result = $statement->fetch(); 46 | 47 | if (false === $result) { 48 | return null; 49 | } 50 | 51 | return $result['settings']; 52 | } 53 | 54 | /** 55 | * {@inheritdoc} 56 | */ 57 | public function set($key, $value) 58 | { 59 | if (null === $this->get($key)) { 60 | $sql = self::STMT_INSERT; 61 | } else { 62 | $sql = self::STMT_UPDATE; 63 | } 64 | 65 | $statement = $this->pdoConnection->prepare($this->getSQLStatement($sql)); 66 | 67 | $statement->bindParam('key', $key); 68 | $statement->bindParam('value', $value); 69 | 70 | $statement->execute(); 71 | } 72 | 73 | /** 74 | * @param string $key 75 | */ 76 | public function remove($key) 77 | { 78 | $statement = $this->pdoConnection->prepare($this->getSQLStatement(self::STMT_DELETE)); 79 | 80 | $statement->bindParam('key', $key); 81 | $statement->execute(); 82 | } 83 | 84 | /** 85 | * @param string $sql 86 | * 87 | * @return string 88 | */ 89 | private function getSQLStatement($sql) 90 | { 91 | return str_replace(':table', $this->tableName, $sql); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/Storage/RedisStorageAdapter.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | class RedisStorageAdapter implements StorageInterface 11 | { 12 | /** 13 | * @var string 14 | */ 15 | const DEFAULT_GROUP = 'rollout_feature'; 16 | 17 | /** 18 | * @var object 19 | */ 20 | private $redis; 21 | 22 | /** 23 | * @var string 24 | */ 25 | private $group = self::DEFAULT_GROUP; 26 | 27 | public function __construct($redis, $group = null) 28 | { 29 | $this->redis = $redis; 30 | 31 | if ($group) { 32 | $this->group = $group; 33 | } 34 | } 35 | 36 | /** 37 | * @inheritdoc 38 | */ 39 | public function get($key) 40 | { 41 | $result = $this->redis->hget($this->group, $key); 42 | 43 | if (empty($result)) { 44 | return null; 45 | } 46 | 47 | $result = json_decode($result, true); 48 | 49 | if (JSON_ERROR_NONE !== json_last_error()) { 50 | return null; 51 | } 52 | 53 | return $result; 54 | } 55 | 56 | /** 57 | * @inheritdoc 58 | */ 59 | public function set($key, $value) 60 | { 61 | $this->redis->hset($this->group, $key, json_encode($value)); 62 | } 63 | 64 | /** 65 | * @inheritdoc 66 | */ 67 | public function remove($key) 68 | { 69 | $this->redis->hdel($this->group, $key); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Storage/StorageInterface.php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | interface StorageInterface 12 | { 13 | /** 14 | * @param string $key 15 | * @return string|null Null if the value is not found 16 | */ 17 | public function get($key); 18 | 19 | /** 20 | * @param string $key 21 | * @param string $value 22 | * @return void 23 | */ 24 | public function set($key, $value); 25 | 26 | /** 27 | * @param string $key 28 | * @return void 29 | */ 30 | public function remove($key); 31 | } 32 | -------------------------------------------------------------------------------- /tests/FeatureTest.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | class FeatureTest extends \PHPUnit_Framework_TestCase 9 | { 10 | public function testParseOldSettingsFormat() 11 | { 12 | $feature = new Feature('chat', '100|4,12|fivesonly'); 13 | 14 | $this->assertEquals(100, $feature->getPercentage()); 15 | $this->assertEquals([4, 12], $feature->getUsers()); 16 | $this->assertEquals(['fivesonly'], $feature->getGroups()); 17 | } 18 | 19 | public function testParseNewSettingsFormat() 20 | { 21 | $feature = new Feature('chat', '100|4,12|fivesonly|FF_facebookIntegration=1'); 22 | 23 | $this->assertEquals(100, $feature->getPercentage()); 24 | $this->assertEquals([4, 12], $feature->getUsers()); 25 | $this->assertEquals(['fivesonly'], $feature->getGroups()); 26 | $this->assertEquals('FF_facebookIntegration=1', $feature->getRequestParam()); 27 | } 28 | 29 | public function testParseDataSettingsFormat() 30 | { 31 | $feature = new Feature('chat', '100|4,12|fivesonly|FF_facebookIntegration=1|{"description":"foo","release_date":"bar"}'); 32 | 33 | $this->assertEquals(100, $feature->getPercentage()); 34 | $this->assertEquals([4, 12], $feature->getUsers()); 35 | $this->assertEquals(['fivesonly'], $feature->getGroups()); 36 | $this->assertEquals('FF_facebookIntegration=1', $feature->getRequestParam()); 37 | $this->assertEquals('{"description":"foo","release_date":"bar"}', json_encode($feature->getData())); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /tests/RolloutTest.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | class RolloutTest extends \PHPUnit_Framework_TestCase 14 | { 15 | /** 16 | * @var Rollout 17 | */ 18 | private $rollout; 19 | 20 | protected function setUp() 21 | { 22 | $this->rollout = new Rollout(new ArrayStorage()); 23 | } 24 | 25 | public function testActiveForBlockGroup() 26 | { 27 | // When a group is activated 28 | $this->rollout->defineGroup('fivesonly', function(RolloutUserInterface $user) { return $user->getRolloutIdentifier() == 5; }); 29 | $this->rollout->activateGroup('chat', 'fivesonly'); 30 | 31 | // the feature is active for users for which the callback evaluates as true 32 | $this->assertTrue($this->rollout->isActive('chat', new RolloutUser(5))); 33 | 34 | // is not active for users for which the callback evalutates to false 35 | $this->assertFalse($this->rollout->isActive('chat', new RolloutUser(1))); 36 | 37 | // is not active if a group is found in storage, but not defined in the rollout 38 | $this->rollout->activateGroup('chat', 'fake'); 39 | $this->assertFalse($this->rollout->isActive('chat', new RolloutUser(1))); 40 | } 41 | 42 | public function testDefaultAllGroup() 43 | { 44 | // the default all group 45 | $this->rollout->activateGroup('chat', 'all'); 46 | 47 | // evaluates to true no matter what 48 | $this->assertTrue($this->rollout->isActive('chat', new RolloutUser(0))); 49 | } 50 | 51 | public function testDeactivatingAGroup() 52 | { 53 | $this->rollout->defineGroup('fivesonly', function(RolloutUserInterface $user) { return $user->getRolloutIdentifier() == 5; }); 54 | $this->rollout->activateGroup('chat', 'all'); 55 | $this->rollout->activateGroup('chat', 'some'); 56 | $this->rollout->activateGroup('chat', 'fivesonly'); 57 | $this->rollout->deactivateGroup('chat', 'all'); 58 | $this->rollout->deactivateGroup('chat', 'some'); 59 | 60 | // deactivates the rules for that group 61 | $this->assertFalse($this->rollout->isActive('chat', new RolloutUser(10))); 62 | 63 | // leaves the other groups active 64 | $this->assertContains('fivesonly', $this->rollout->get('chat')->getGroups()); 65 | $this->assertCount(1, $this->rollout->get('chat')->getGroups()); 66 | } 67 | 68 | public function testDeactivatingAFeatureCompletely() 69 | { 70 | $this->rollout->defineGroup('fivesonly', function(RolloutUserInterface $user) { return $user->getRolloutIdentifier() === 5; }); 71 | $this->rollout->activateGroup('chat', 'all'); 72 | $this->rollout->activateGroup('chat', 'fivesonly'); 73 | $this->rollout->activateUser('chat', new RolloutUser(51)); 74 | $this->rollout->activatePercentage('chat', 100); 75 | $this->rollout->activateRequestParam('chat', 'FF_facebookIntegration=1'); 76 | $this->rollout->activate('chat'); 77 | $this->rollout->deactivate('chat'); 78 | 79 | // it should remove all of the groups 80 | $this->assertFalse($this->rollout->isActive('chat', new RolloutUser(0))); 81 | 82 | // it should remove all of the users 83 | $this->assertFalse($this->rollout->isActive('chat', new RolloutUser(51))); 84 | 85 | // it should remove the percentage 86 | $this->assertFalse($this->rollout->isActive('chat', new RolloutUser(24))); 87 | 88 | // it should remove the request param 89 | $this->assertFalse($this->rollout->isActive('chat', null, array('FF_facebookIntegration', true))); 90 | 91 | // it should be removed globally 92 | $this->assertFalse($this->rollout->isActive('chat')); 93 | } 94 | 95 | public function testActivatingASpecificUser() 96 | { 97 | $this->rollout->activateUser('chat', new RolloutUser(42)); 98 | 99 | // it should be active for that user 100 | $this->assertTrue($this->rollout->isActive('chat', new RolloutUser(42))); 101 | 102 | // it remains inactive for other users 103 | $this->assertFalse($this->rollout->isActive('chat', new RolloutUser(24))); 104 | } 105 | 106 | public function testActivatingASpecificUserWithStringId() 107 | { 108 | $this->rollout->activateUser('chat', new RolloutUser('user-72')); 109 | 110 | // it should be active for that user 111 | $this->assertTrue($this->rollout->isActive('chat', new RolloutUser('user-72'))); 112 | 113 | // it remains inactive for other users 114 | $this->assertFalse($this->rollout->isActive('chat', new RolloutUser('user-12'))); 115 | } 116 | 117 | public function testDeactivatingASpecificUser() 118 | { 119 | $this->rollout->activateUser('chat', new RolloutUser(42)); 120 | $this->rollout->activateUser('chat', new RolloutUser(4242)); 121 | $this->rollout->activateUser('chat', new RolloutUser(24)); 122 | $this->rollout->deactivateUser('chat', new RolloutUser(42)); 123 | $this->rollout->deactivateUser('chat', new RolloutUser('4242')); 124 | 125 | // that user should no longer be active 126 | $this->assertFalse($this->rollout->isActive('chat', new RolloutUser(42))); 127 | 128 | // it remains active for other users 129 | $users = $this->rollout->get('chat')->getUsers(); 130 | $this->assertCount(1, $users); 131 | $this->assertEquals(24, $users[0]); 132 | } 133 | 134 | public function testActivatingAFeatureGlobally() 135 | { 136 | $this->rollout->activate('chat'); 137 | 138 | // it should activate the feature 139 | $this->assertTrue($this->rollout->isActive('chat')); 140 | } 141 | 142 | public function testActivatingAFeatureForPercentageOfUsers() 143 | { 144 | $this->rollout->activatePercentage('chat', 20); 145 | 146 | $activated = array(); 147 | foreach (range(1, 120) as $id) { 148 | if ($this->rollout->isActive('chat', new RolloutUser($id))) { 149 | $activated[] = true; 150 | } 151 | } 152 | 153 | // it should activate the feature for a percentage of users 154 | $this->assertLessThanOrEqual(21, count($activated)); 155 | $this->assertGreaterThanOrEqual(19, count($activated)); 156 | } 157 | 158 | public function testActivatingAFeatureForPercentageOfUsers2() 159 | { 160 | $this->rollout->activatePercentage('chat', 20); 161 | 162 | $activated = array(); 163 | foreach (range(1, 200) as $id) { 164 | if ($this->rollout->isActive('chat', new RolloutUser($id))) { 165 | $activated[] = true; 166 | } 167 | } 168 | 169 | // it should activate the feature for a percentage of users 170 | $this->assertLessThanOrEqual(45, count($activated)); 171 | $this->assertGreaterThanOrEqual(35, count($activated)); 172 | } 173 | 174 | public function testActivatingAFeatureForPercentageOfUsers3() 175 | { 176 | $this->rollout->activatePercentage('chat', 5); 177 | 178 | $activated = array(); 179 | foreach (range(1, 100) as $id) { 180 | if ($this->rollout->isActive('chat', new RolloutUser($id))) { 181 | $activated[] = true; 182 | } 183 | } 184 | 185 | // it should activate the feature for a percentage of users 186 | $this->assertLessThanOrEqual(7, count($activated)); 187 | $this->assertGreaterThanOrEqual(3, count($activated)); 188 | } 189 | 190 | public function testActivatingAFeatureForAGroupAsAString() 191 | { 192 | $this->rollout->defineGroup('admins', function(RolloutUserInterface $user) { return $user->getRolloutIdentifier() == 5; }); 193 | $this->rollout->activateGroup('chat', 'admins'); 194 | 195 | // the feature is active for users for which the block is true 196 | $this->assertTrue($this->rollout->isActive('chat', new RolloutUser(5))); 197 | 198 | // the feature is not active for users for which the block evaluates to false 199 | $this->assertFalse($this->rollout->isActive('chat', new RolloutUser(1))); 200 | } 201 | 202 | public function testDeactivatingThePercentageOfUsers() 203 | { 204 | $this->rollout->activatePercentage('chat', 100); 205 | $this->rollout->deactivatePercentage('chat'); 206 | 207 | // it becomes inactive for all users 208 | $this->assertFalse($this->rollout->isActive('chat', new RolloutUser(24))); 209 | } 210 | 211 | public function testActivatingRequestParam() 212 | { 213 | $this->rollout->activateRequestParam('chat', 'FF_facebookIntegration=1'); 214 | 215 | $this->assertTrue($this->rollout->isActive('chat', null, ['FF_facebookIntegration' => true])); 216 | 217 | $this->assertFalse($this->rollout->isActive('chat', null, ['FF_anotherFeature' => true])); 218 | } 219 | 220 | public function testDeactivatingRequestParam() 221 | { 222 | $this->rollout->activateRequestParam('chat', 'FF_facebookIntegration=1'); 223 | $this->rollout->deactivateRequestParam('chat'); 224 | 225 | $this->assertFalse($this->rollout->isActive('chat', null, ['FF_facebookIntegration' => true])); 226 | 227 | $this->assertFalse($this->rollout->isActive('chat', null, ['FF_anotherFeature' => true])); 228 | } 229 | 230 | public function testDeactivatingTheFeatureGlobally() 231 | { 232 | $this->rollout->activate('chat'); 233 | $this->rollout->deactivate('chat'); 234 | 235 | // inactive feature 236 | $this->assertFalse($this->rollout->isActive('chat')); 237 | } 238 | 239 | public function testKeepsAListOfFeatures() 240 | { 241 | // saves the feature 242 | $this->rollout->activate('chat'); 243 | $this->assertContains('chat', $this->rollout->features()); 244 | 245 | // does not contain doubles 246 | $this->rollout->activate('chat'); 247 | $this->rollout->activate('chat'); 248 | $this->assertCount(1, $this->rollout->features()); 249 | } 250 | 251 | public function testGet() 252 | { 253 | $this->rollout->activatePercentage('chat', 10); 254 | $this->rollout->activateGroup('chat', 'caretakers'); 255 | $this->rollout->activateGroup('chat', 'greeters'); 256 | $this->rollout->activate('signup'); 257 | $this->rollout->activateUser('chat', new RolloutUser(42)); 258 | $this->rollout->activateRequestParam('chat', 'FF_facebookIntegration=1'); 259 | $this->rollout->setFeatureData('chat', array( 260 | 'description' => 'foo', 261 | 'release_date' => 'bar', 262 | 'whatever' => 'baz' 263 | )); 264 | 265 | // it should return the feature object 266 | $feature = $this->rollout->get('chat'); 267 | $this->assertContains('caretakers', $feature->getGroups()); 268 | $this->assertContains('greeters', $feature->getGroups()); 269 | $this->assertEquals(10, $feature->getPercentage()); 270 | $this->assertContains(42, $feature->getUsers()); 271 | $this->assertEquals( 272 | array( 273 | 'groups' => array('caretakers', 'greeters'), 274 | 'percentage' => 10, 275 | 'users' => array('42'), 276 | 'requestParam' => 'FF_facebookIntegration=1', 277 | 'data' => array( 278 | 'description' => 'foo', 279 | 'release_date' => 'bar', 280 | 'whatever' => 'baz' 281 | ) 282 | ), 283 | $feature->toArray() 284 | ); 285 | 286 | $feature = $this->rollout->get('signup'); 287 | $this->assertEmpty($feature->getGroups()); 288 | $this->assertEmpty($feature->getUsers()); 289 | $this->assertEquals(100, $feature->getPercentage()); 290 | $this->assertEmpty($feature->getRequestParam()); 291 | $this->assertEmpty($feature->getData()); 292 | } 293 | 294 | public function testRemove() 295 | { 296 | $this->rollout->activate('signup'); 297 | $feature = $this->rollout->get('signup'); 298 | $this->assertEquals('signup', $feature->getName()); 299 | 300 | $this->rollout->remove('signup'); 301 | $this->assertNotContains('signup', $this->rollout->features()); 302 | } 303 | 304 | public function testClearFeatureData() 305 | { 306 | $this->rollout->activate('signup'); 307 | $feature = $this->rollout->get('signup'); 308 | $this->assertEquals('signup', $feature->getName()); 309 | 310 | $this->rollout->setFeatureData('signup', array( 311 | 'description' => 'foo', 312 | 'release_date' => 'bar', 313 | 'whatever' => 'baz' 314 | )); 315 | 316 | $feature = $this->rollout->get('signup'); 317 | 318 | $this->assertEquals(array( 319 | 'description' => 'foo', 320 | 'release_date' => 'bar', 321 | 'whatever' => 'baz' 322 | ), $feature->getData()); 323 | 324 | $this->rollout->clearFeatureData('signup'); 325 | 326 | $feature = $this->rollout->get('signup'); 327 | 328 | $this->assertEmpty($feature->getData()); 329 | } 330 | } 331 | 332 | 333 | /** 334 | * @author Richard Fullmer 335 | */ 336 | class RolloutUser implements RolloutUserInterface 337 | { 338 | /** 339 | * @var string 340 | */ 341 | private $id; 342 | 343 | /** 344 | * @param string $id 345 | */ 346 | public function __construct($id) 347 | { 348 | $this->id = $id; 349 | } 350 | 351 | /** 352 | * @return string 353 | */ 354 | public function getRolloutIdentifier() 355 | { 356 | return $this->id; 357 | } 358 | } 359 | -------------------------------------------------------------------------------- /tests/Storage/MongoDBStorageAdapterTest.php: -------------------------------------------------------------------------------- 1 | mongo = new mockMongo(); 17 | $collection = $this->mockCollection(); 18 | $collection->method('findOne')->will($this->returnValue(['name' => 'key', 'value' => true])); 19 | $collection->method('update')->will($this->returnValue(null)); 20 | 21 | $this->mongo->setCollection($collection); 22 | } 23 | 24 | public function testGet() 25 | { 26 | 27 | $adapter = new MongoDBStorageAdapter($this->mongo); 28 | 29 | $result = $adapter->get('key'); 30 | $this->assertSame(true, $result); 31 | } 32 | 33 | public function testGetNoValue() 34 | { 35 | 36 | $adapter = new MongoDBStorageAdapter($this->mongo); 37 | $this->mongo->coll->method('findOne')->will($this->returnValue(null)); 38 | $result = $adapter->get('key'); 39 | $this->assertSame(true, $result); 40 | } 41 | 42 | public function testSet() 43 | { 44 | 45 | $adapter = new MongoDBStorageAdapter($this->mongo); 46 | 47 | $adapter->set('key', 'value'); 48 | 49 | } 50 | 51 | public function testRemove() 52 | { 53 | 54 | $adapter = new MongoDBStorageAdapter($this->mongo); 55 | 56 | $adapter->remove('key'); 57 | 58 | } 59 | 60 | public function testGetCollectionName() 61 | { 62 | 63 | $adapter = new MongoDBStorageAdapter($this->mongo, 'feature_test'); 64 | 65 | $result = $adapter->getCollectionName(); 66 | $this->assertSame('feature_test', $result); 67 | 68 | } 69 | 70 | public function mockCollection() 71 | { 72 | return $this->getMockBuilder('MongoCollection') 73 | ->disableOriginalConstructor() 74 | ->setMethods(array( 75 | 'find', 76 | 'findOne', 77 | 'update', 78 | 'remove', 79 | )) 80 | ->getMock(); 81 | 82 | } 83 | } 84 | 85 | class mockMongo 86 | { 87 | public $collection; 88 | public function __construct() 89 | { 90 | 91 | } 92 | 93 | public function setCollection($mongoCollection) 94 | { 95 | $this->collection = $mongoCollection; 96 | } 97 | 98 | public function __get($name) 99 | { 100 | return $this->collection; 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /tests/Storage/PDOStorageAdapterTest.php: -------------------------------------------------------------------------------- 1 | mockPDOStatement(); 12 | 13 | $statement->expects($this->once()) 14 | ->method('bindParam') 15 | ->with('key', 'test'); 16 | 17 | $statement->expects($this->once()) 18 | ->method('execute'); 19 | 20 | $statement->expects($this->once()) 21 | ->method('fetch') 22 | ->willReturn(array('settings' => 'success')); 23 | 24 | $pdo = $this->mockPDO($this->prepareSQL(PDOStorageAdapter::STMT_SELECT), $statement); 25 | 26 | $adapter = new PDOStorageAdapter($pdo); 27 | 28 | $result = $adapter->get('test'); 29 | $this->assertEquals('success', $result); 30 | } 31 | 32 | public function testRemove() 33 | { 34 | $statement = $this->mockPDOStatement(); 35 | 36 | $statement->expects($this->once()) 37 | ->method('bindParam') 38 | ->with('key', 'test'); 39 | 40 | $statement->expects($this->once()) 41 | ->method('execute'); 42 | 43 | $pdo = $this->mockPDO($this->prepareSQL(PDOStorageAdapter::STMT_DELETE), $statement); 44 | 45 | $adapter = new PDOStorageAdapter($pdo); 46 | $adapter->remove('test'); 47 | } 48 | 49 | public function testTableName() 50 | { 51 | $statement = $this->mockPDOStatement(); 52 | 53 | $pdo = $this->mockPDO($this->prepareSQL(PDOStorageAdapter::STMT_SELECT, 'rollout_feature2'), $statement); 54 | 55 | $adapter = new PDOStorageAdapter($pdo, 'rollout_feature2'); 56 | 57 | $adapter->get('test'); 58 | } 59 | 60 | public function testSetInsert() 61 | { 62 | $getStatement = $this->mockPDOStatement(); 63 | 64 | $setStatement = $this->mockPDOStatement(); 65 | $setStatement->expects($this->at(0)) 66 | ->method('bindParam') 67 | ->with('key', 'test'); 68 | 69 | $setStatement->expects($this->at(1)) 70 | ->method('bindParam') 71 | ->with('value', 'value'); 72 | 73 | $pdo = $this->mockPDO($this->prepareSQL(PDOStorageAdapter::STMT_SELECT), $getStatement); 74 | 75 | $pdo->expects($this->at(1)) 76 | ->method('prepare') 77 | ->with($this->prepareSQL(PDOStorageAdapter::STMT_INSERT)) 78 | ->willReturn($setStatement); 79 | 80 | $adapter = new PDOStorageAdapter($pdo, 'rollout_feature'); 81 | 82 | $adapter->set('test', 'value'); 83 | } 84 | 85 | public function testSetUpdate() 86 | { 87 | $getStatement = $this->mockPDOStatement(); 88 | $getStatement->expects($this->once()) 89 | ->method('fetch') 90 | ->willReturn(array('settings' => 'success')); 91 | 92 | $setStatement = $this->mockPDOStatement(); 93 | $setStatement->expects($this->at(0)) 94 | ->method('bindParam') 95 | ->with('key', 'test'); 96 | 97 | $setStatement->expects($this->at(1)) 98 | ->method('bindParam') 99 | ->with('value', 'value'); 100 | 101 | $pdo = $this->mockPDO($this->prepareSQL(PDOStorageAdapter::STMT_SELECT), $getStatement); 102 | 103 | $this->mockPDOPrepare($pdo, 1, $this->prepareSQL(PDOStorageAdapter::STMT_UPDATE), $setStatement); 104 | 105 | $adapter = new PDOStorageAdapter($pdo, 'rollout_feature'); 106 | 107 | $adapter->set('test', 'value'); 108 | } 109 | 110 | private function prepareSQL($sql, $table = 'rollout_feature') 111 | { 112 | return str_replace(':table', $table, $sql); 113 | } 114 | 115 | /** 116 | * @return \PHPUnit_Framework_MockObject_MockObject 117 | */ 118 | private function mockPDOStatement() 119 | { 120 | $statement = $this->getMockBuilder('\PDOStatement') 121 | ->getMock(); 122 | 123 | return $statement; 124 | } 125 | 126 | /** 127 | * @param $query 128 | * @param $statement 129 | * 130 | * @return \PHPUnit_Framework_MockObject_MockObject 131 | */ 132 | private function mockPDO($query, $statement) 133 | { 134 | $pdo = $this->getMockBuilder('\Opensoft\Tests\Storage\mockPDO') 135 | ->getMock(); 136 | 137 | $this->mockPDOPrepare($pdo, 0, $query, $statement); 138 | 139 | return $pdo; 140 | } 141 | 142 | private function mockPDOPrepare(\PHPUnit_Framework_MockObject_MockObject $pdo, $at, $query, $statement) 143 | { 144 | $pdo->expects($this->at($at)) 145 | ->method('prepare') 146 | ->with($query) 147 | ->willReturn($statement); 148 | } 149 | } 150 | 151 | class mockPDO extends \PDO 152 | { 153 | public function __construct() 154 | { 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /tests/Storage/RedisStorageAdapterTest.php: -------------------------------------------------------------------------------- 1 | redis = $this->getMockBuilder('\Opensoft\Tests\Storage\mockRedis')->getMock(); 14 | } 15 | 16 | public function testGet() 17 | { 18 | $this->redis->expects($this->once()) 19 | ->method('hget') 20 | ->with(RedisStorageAdapter::DEFAULT_GROUP, 'key') 21 | ->willReturn(json_encode('success')); 22 | 23 | $adapter = new RedisStorageAdapter($this->redis); 24 | 25 | $result = $adapter->get('key'); 26 | $this->assertSame('success', $result); 27 | } 28 | 29 | public function testGetWithCustomGroup() 30 | { 31 | $this->redis->expects($this->once()) 32 | ->method('hget') 33 | ->with('rollout_test', 'key') 34 | ->willReturn(json_encode('success')); 35 | 36 | $adapter = new RedisStorageAdapter($this->redis, 'rollout_test'); 37 | 38 | $result = $adapter->get('key'); 39 | $this->assertSame('success', $result); 40 | } 41 | 42 | public function testGetNotExistsFailure() 43 | { 44 | $this->redis->expects($this->once()) 45 | ->method('hget') 46 | ->with(RedisStorageAdapter::DEFAULT_GROUP, 'key') 47 | ->willReturn(''); 48 | 49 | $adapter = new RedisStorageAdapter($this->redis); 50 | 51 | $result = $adapter->get('key'); 52 | $this->assertNull($result); 53 | } 54 | 55 | public function testGetJsonDecodeFailure() 56 | { 57 | $this->redis->expects($this->once()) 58 | ->method('hget') 59 | ->with(RedisStorageAdapter::DEFAULT_GROUP, 'key') 60 | ->willReturn('not json'); 61 | 62 | $adapter = new RedisStorageAdapter($this->redis); 63 | 64 | $result = $adapter->get('key'); 65 | $this->assertNull($result); 66 | } 67 | 68 | public function testSet() 69 | { 70 | $this->redis->expects($this->once()) 71 | ->method('hset') 72 | ->with(RedisStorageAdapter::DEFAULT_GROUP, 'key', json_encode('value')); 73 | 74 | $adapter = new RedisStorageAdapter($this->redis); 75 | 76 | $adapter->set('key', 'value'); 77 | } 78 | 79 | public function testRemove() 80 | { 81 | $this->redis->expects($this->once()) 82 | ->method('hdel') 83 | ->with(RedisStorageAdapter::DEFAULT_GROUP, 'key'); 84 | 85 | $adapter = new RedisStorageAdapter($this->redis); 86 | 87 | $adapter->remove('key'); 88 | } 89 | 90 | public function testSetWithCustomGroup() 91 | { 92 | $this->redis->expects($this->once()) 93 | ->method('hset') 94 | ->with('rollout_test', 'key', json_encode('value')); 95 | 96 | $adapter = new RedisStorageAdapter($this->redis, 'rollout_test'); 97 | 98 | $adapter->set('key', 'value'); 99 | } 100 | } 101 | 102 | interface mockRedis 103 | { 104 | public function hget($key, $field); 105 | public function hset($key, $field, $value); 106 | public function hdel($key, $field); 107 | } 108 | --------------------------------------------------------------------------------