├── .gitignore ├── .travis.yml ├── phpunit.xml ├── src └── Sudzy │ ├── ValidationException.php │ └── ValidModel.php ├── composer.json ├── test ├── bootstrap.php └── SudzyTest.php └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | composer.lock 2 | vendor/* 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | php: 3 | - '5.6' 4 | - '7.0' 5 | - '7.1' 6 | - hhvm 7 | 8 | before_script: 9 | - export PATH="$PATH:$HOME/.composer/vendor/bin" 10 | - composer global require "squizlabs/php_codesniffer=*" 11 | 12 | install: composer install 13 | 14 | script: 15 | - phpcs --config-set ignore_warnings_on_exit 1 16 | - phpcs --standard=PSR2 ./src --colors 17 | - "phpunit --colors --coverage-text" 18 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | test 9 | 10 | 11 | 12 | 13 | 14 | ./src 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/Sudzy/ValidationException.php: -------------------------------------------------------------------------------- 1 | _validationExceptions = $exceptions; 12 | $this->_validationErrors = $errs; 13 | 14 | $errs = array_map( 15 | function ($val) { 16 | return implode("\n", $val); 17 | }, 18 | $errs 19 | ); 20 | $errStr = implode("\n", $errs); 21 | parent::__construct($errStr); 22 | } 23 | 24 | public function getValidationErrors() 25 | { 26 | return $this->_validationErrors; 27 | } 28 | 29 | public function getValidationExceptions() 30 | { 31 | return $this->_validationExceptions; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tag/sudzy", 3 | "description": "Model validator for use with Paris (and Idiorm).", 4 | "keywords": ["validator", "model","idiorm", "paris"], 5 | "homepage": "https://github.com/tag/sudzy", 6 | "license": "BSD", 7 | "authors": [ 8 | { 9 | "name": "Tom Gregory", 10 | "email": "tom@alt-tag.com", 11 | "homepage": "http://alt-tag.com/", 12 | "role": "Developer" 13 | } 14 | ], 15 | "repository": { 16 | "type": "vcs", 17 | "url": "https://github.com/tag/sudzy" 18 | }, 19 | "require": { 20 | "php": ">=5.6.0", 21 | "j4mie/paris": ">=1.4", 22 | "respect/validation": "^1.1" 23 | }, 24 | "require-dev": { 25 | "phpunit/phpunit": "^5.7" 26 | }, 27 | "autoload": { 28 | "psr-0": { 29 | "Sudzy": "src", 30 | "ValidationException": "src" 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /test/bootstrap.php: -------------------------------------------------------------------------------- 1 | current_row == 5) { 23 | return false; 24 | } else { 25 | return array('name' => 'Fred', 'email' => 'jim@example.com', 'age'=>34, 'id' => ++$this->current_row); 26 | } 27 | } 28 | } 29 | 30 | /** 31 | * Mock database class implementing a subset 32 | * of the PDO API. 33 | */ 34 | class MockPDO extends PDO { 35 | 36 | /** 37 | * Return a dummy PDO statement 38 | */ 39 | public function prepare($statement, $driver_options=array()) { 40 | return new MockPDOStatement($statement); 41 | } 42 | } 43 | 44 | /** 45 | * Models for use during testing 46 | */ 47 | class Simple extends \Sudzy\ValidModel { 48 | public function prepareValidations() 49 | { 50 | $this->setValidation('age', \Respect\Validation\Validator::intVal()); 51 | 52 | } 53 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sudzy [![Build Status](https://travis-ci.org/tag/sudzy.png?branch=master)](https://travis-ci.org/tag/sudzy) 2 | **Breaking change on v0.3: Validations now use `respect/validation` validation library.** 3 | 4 | Sudzy implements validarion against model classes using 5 | [Paris][paris]/[Idiorm][idiorm] (an active record ORM, often used with Slim), 6 | although it could be adapted easily. 7 | 8 | Sudzy's `ValidModel` class decorates Paris' `Model` class. By extending 9 | `ValidModel`, your model classes gain immediate access to validations. 10 | 11 | By default the `ValidModel` will store validation errors when model properties 12 | are set (for an exising model) or a new model is saved, and throw a 13 | `ValidationException` on save if errors were encountered. 14 | 15 | Sudzy's `ValidModel` class uses [`Respect/Validation`][respect] as its validation 16 | engine. See that project for details. 17 | 18 | [paris]: https://github.com/j4mie/paris 19 | [idiorm]: https://github.com/j4mie/idiorm 20 | [respect]: https://github.com/Respect/Validation 21 | 22 | ### Installation 23 | The easiest way to install Sudzy is via [Composer][composer]. Start by creating or adding to your project's `composer.json` file: 24 | 25 | ```js 26 | { 27 | "require": { 28 | "tag/sudzy" : "dev-master" // Grab the most recent version from github 29 | } 30 | } 31 | ``` 32 | 33 | [composer]: http://getcomposer.org 34 | 35 | ## ValidModel Example 36 | The `ValidModel` class requires you to implement the abstract method 37 | `#prepareValidations()`, in order to lazily load the validations. Thus, 38 | constructors will not have the overhead of creating unused validation objects. 39 | 40 | Validations can also be added at any time with the `#addValidation()` method. 41 | 42 | The `#setValidation()` method is passed the model property to watch, and a 43 | Respect validation object to be checked against. Multiple calls on the same 44 | property overwrite previous validations. 45 | 46 | `Respect\Validation` is namespaced, but you can make your life easier by importing a single class into your context: 47 | 48 | ```php 49 | use Respect\Validation\Validator as v; 50 | ``` 51 | 52 | ```php 53 | // Within a `ValidModel` class declaration: 54 | 55 | public function prepareValidation() 56 | { 57 | $this->setValidation('username', v::alnum()->noWhitespace()->length(1, 15) ); 58 | $this->setValidation('email', v::email() ); 59 | $this->setValidation('password', v::stringType()->length(6, null)->length(1, 15) ); 60 | $this->setValidation('birthdate', v::date()->age(18)); 61 | 62 | } 63 | ``` 64 | When using `Respect\Validation`, create different validations for each field, instead of a single validator for the entire object. 65 | 66 | ### Full Example 67 | Example model class: 68 | 69 | ```php 70 | namespace Models; 71 | 72 | use Respect\Validation\Validator as v; 73 | 74 | class User extends \Sudzy\ValidModel 75 | { 76 | public function prepareValidation() 77 | { 78 | $this->setValidation('username', v::alnum()->noWhitespace()->length(1, 15) ); 79 | $this->setValidation('email', v::email() ); 80 | $this->setValidation('password', v::stringType()->length(6, null)->length(1, 15) ); 81 | $this->setValidation('birthdate', v::date()->age(18)); 82 | } 83 | } 84 | ``` 85 | 86 | Example controller snip: 87 | 88 | ```php 89 | // This example assumes Slim context and access to flash messages 90 | // ... ... 91 | 92 | $newUser = Model::factory('\Models\User')->create(); 93 | 94 | try { 95 | $newUser->email = $_POST['email']; 96 | $newUser->password = $_POST['password']; 97 | 98 | $newWard->save(); 99 | 100 | $this->flash->addMessage('success', 'New User created.'); 101 | } catch (Sudzy\ValidationException $sve) { 102 | foreach ($sve->getMessages() as $msg) { 103 | $this->flash->addMessage('error', $msg); 104 | } 105 | } 106 | ``` 107 | 108 | ### Validation Exceptions and Errors 109 | By default, Sudzy's `ValidModel` does validation checks whenever objects are 110 | committed to the database via `#save()`, but can be configured to throw an 111 | exception when properties are set, or not at all. 112 | 113 | Because an object can have multiple fields fail, it is necessary to catch and 114 | wrap Respect's exceptions. 115 | 116 | :TODO: 117 | 118 | Validation failures are stored, and available through `getValidationErrors()`, 119 | a method of both the `ValidModel` object and the thrown `ValidationException`. 120 | An object that fails validation throws a `ValidationException` when `save()` is 121 | attempted (default behavior). This can be changed to `::ON_SET` or `::NEVER` by 122 | setting the `throw` option: 123 | 124 | ```php 125 | $model->setValidationOptions( 126 | array('throw' => self::ON_SET) 127 | ); 128 | ``` 129 | 130 | Be careful of using `::ON_SET`, as Paris' internal `set()` method is not called 131 | when a model is built via Paris' `hydrate()` or `create()` methods. Also, 132 | `::ON_SET` tiggers the validation exception immediately, whereas `::ON_SAVE` 133 | permits validating all fields before throwing an exception. 134 | 135 | Regardless of the value of the `throw` option, validations are checked when 136 | properties are set. In the case of new models (such as one built with Paris 137 | methods `create()` or `hydrate()`), validations are also checked on save. 138 | Regardless of when exceptions are thrown (or not), errors are immediately 139 | available through `getValidationErrors()`. 140 | -------------------------------------------------------------------------------- /src/Sudzy/ValidModel.php: -------------------------------------------------------------------------------- 1 | false, // If True getValidationErrors will return an array with the index 15 | // being the field name and the value an *array* of errors. 16 | 17 | 'throw' => self::VALIDATE_ON_SAVE // One of self::ON_SET|ON_SAVE|NEVER. 18 | // + ON_SET throws immediately when field is set() 19 | // + ON_SAVE throws on save() 20 | // + NEVER means an exception is never thrown; check for ->getValidationErrors() 21 | ]; 22 | 23 | const VALIDATE_ON_SET = 'set'; 24 | const VALIDATE_ON_SAVE = 'save'; 25 | const VALIDATE_NEVER = null; 26 | 27 | public function setValidationOptions($options) 28 | { 29 | $this->_validationOptions = array_merge($this->_validationOptions, $options); 30 | } 31 | 32 | public function getValidationOptions() 33 | { 34 | return $this->_validationOptions; 35 | } 36 | 37 | abstract public function prepareValidations(); 38 | 39 | /** 40 | * @param string $prop Property name to be validated 41 | * @param object $validator An instance of Respect\Validation\Validator 42 | */ 43 | public function setValidation($prop, $validator) 44 | { 45 | $this->lazyLoadValidations(); 46 | if ($validator === null) { 47 | unset($this->_validators[$prop]); 48 | } else { 49 | $this->_validators[$prop] = $validator; 50 | } 51 | } 52 | 53 | /** 54 | * @param string $prop Property name to be validated 55 | * @return object $validator An instance of Respect\Validation\Validator 56 | */ 57 | public function getValidation($prop) 58 | { 59 | $this->lazyLoadValidations(); 60 | return isset($this->_validators[$prop]) ? $this->_validators[$prop] : null; 61 | } 62 | 63 | /** 64 | * @return array Arrany of Respect\Validation\Validator instances 65 | */ 66 | public function getAllValidations() 67 | { 68 | $this->lazyLoadValidations(); 69 | return $this->_validators; 70 | } 71 | 72 | /** 73 | * Manually trigger validation checking 74 | * 75 | * @return bool `true` if passes validation, otherwise, 76 | * throws `Respect\Validation\Exceptions\NestedValidationException` 77 | */ 78 | public function validate() 79 | { 80 | $this->lazyLoadValidations(); 81 | foreach ($this->_validators as $key => $val) { 82 | $this->validateProperty($key, $this->$key); 83 | } 84 | } 85 | 86 | /** 87 | * @param string $prop Property name to be validated 88 | * @param mixed $value Property value to be validated 89 | * @param bool $throw Whether (true) to throw or a consequent validation exception or (false) return false 90 | * @return bool Will set a message if returning false 91 | * @throws Respect\Validation\Exceptions\NestedValidationException If validations fail and options permit throwing 92 | **/ 93 | public function validateProperty($prop, $value, $throw = false) 94 | { 95 | $this->lazyLoadValidations(); 96 | 97 | unset($this->_validationErrors[$prop]); 98 | unset($this->_validationExceptions[$prop]); 99 | 100 | if (!isset($this->_validators[$prop])) { 101 | return true; // No validations, return true by default 102 | } 103 | 104 | try { 105 | $this->_validators[$prop]->assert($value); 106 | } catch (NestedValidationException $validationException) { 107 | $this->_validationErrors[$prop] = $validationException->getMessages(); 108 | $this->_validationExceptions[] = $validationException; 109 | 110 | if (!$throw) { 111 | return false; 112 | } 113 | 114 | throw $validationException; 115 | } 116 | 117 | return true; 118 | } 119 | 120 | public function getValidationErrors() 121 | { 122 | return $this->_validationErrors; 123 | } 124 | 125 | public function getValidationExceptions() 126 | { 127 | return $this->_validationExceptions; 128 | } 129 | 130 | public function resetValidationErrors() 131 | { 132 | $this->_validationErrors = []; 133 | $this->_validationExceptions = []; 134 | } 135 | 136 | /////////////////// 137 | // Overloaded methods 138 | 139 | /** 140 | * Overload __set to call validateAndSet 141 | */ 142 | public function __set($name, $value) 143 | { 144 | return $this->validateAndSet($name, $value); 145 | } 146 | 147 | /** 148 | * Overload save; checks if errors exist before saving 149 | */ 150 | public function save() 151 | { 152 | if ($this->isNew()) { //Properties populated by create() or hydrate() don't pass through set() 153 | $this->validate(); // Check the valide of $this->getValidationErrors rather than the return value here 154 | } 155 | 156 | if (!empty($this->getValidationErrors())) { 157 | $this->doValidationError(self::VALIDATE_ON_SAVE); 158 | } 159 | 160 | return parent::save(); 161 | } 162 | 163 | /** 164 | * Overload set; to call validateAndSet 165 | * // TODO: handle multiple sets if $name is a property=>val array 166 | */ 167 | public function set($name, $value = null) 168 | { 169 | return $this->validateAndSet($name, $value); 170 | } 171 | 172 | //////////////////// 173 | // Protected methods 174 | 175 | protected function lazyLoadValidations() 176 | { 177 | if (!$this->_validatorsLoaded) { 178 | $this->_validatorsLoaded = true; 179 | $this->prepareValidations(); 180 | } 181 | } 182 | 183 | protected function doValidationError($context) 184 | { 185 | if ($context === $this->_validationOptions['throw']) { 186 | throw new \Sudzy\ValidationException($this->_validationErrors, $this->_validationExceptions); 187 | } 188 | } 189 | 190 | /** 191 | * Overload set; to call validateAndSet 192 | * @param string $name Property name 193 | * @param mixed $value Property value 194 | */ 195 | protected function validateAndSet($name, $value) 196 | { 197 | if (!$this->validateProperty($name, $value)) { 198 | $this->doValidationError(self::VALIDATE_ON_SET); 199 | } 200 | 201 | return parent::set($name, $value); 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /test/SudzyTest.php: -------------------------------------------------------------------------------- 1 | find_one(1); 21 | $this->assertNotEmpty($simple->email); 22 | 23 | $simple->setValidation('email', v::notEmpty() ); 24 | $simple->validateProperty('email', $simple->email); //Initially populated with email address 25 | $this->assertEmpty($simple->getValidationErrors()); 26 | 27 | $simple->email = ''; 28 | $this->assertNotEmpty($simple->getValidationErrors()); 29 | 30 | try { 31 | $simple->save(); 32 | } catch (\Sudzy\ValidationException $e) { 33 | $this->assertNotEmpty($e->getValidationErrors()); 34 | $this->assertNotEmpty($e->getValidationExceptions()); 35 | $this->assertInstanceOf( 36 | Respect\Validation\Exceptions\NestedValidationException::class, 37 | $e->getValidationExceptions()[0] 38 | ); 39 | return; 40 | } 41 | $this->fail('ValidationException expected, but not raised.'); 42 | } 43 | 44 | public function testValidationIsInt() { 45 | $simple = Model::factory('Simple')->find_one(1); 46 | 47 | // This validation set in the Simple model 48 | //$simple->setValidation('age', v::intVal()); 49 | 50 | $simple->age = 23; 51 | $this->assertEmpty($simple->getValidationErrors()); 52 | 53 | $simple->age = '24'; 54 | $this->assertEmpty($simple->getValidationErrors()); 55 | 56 | // Test #set(), not just #__set() 57 | $simple->set('age', '3.14159'); 58 | $this->assertNotEmpty($simple->getValidationErrors()); 59 | $this->assertNotEmpty($simple->getValidationExceptions()); 60 | 61 | $simple->resetValidationErrors(); 62 | $this->assertEmpty($simple->getValidationErrors()); 63 | 64 | $simple->age = false; 65 | $this->assertNotEmpty($simple->getValidationErrors()); 66 | 67 | $simple->resetValidationErrors(); 68 | $this->assertEmpty($simple->getValidationErrors()); 69 | 70 | $simple->age = 'orange'; 71 | $this->assertNotEmpty($simple->getValidationErrors()); 72 | } 73 | 74 | //superfluous test, but useful to see an example of email validation 75 | public function testValidationEmail() { 76 | $simple = Model::factory('Simple')->find_one(1); 77 | $simple->setValidation('email', v::notEmpty()->email() ); 78 | 79 | $simple->email = 'valid@example.com'; 80 | $this->assertEmpty($simple->getValidationErrors()); 81 | 82 | $simple->email = 'invalid@@example.com'; // Incorrect email 83 | $this->assertNotEmpty($simple->getValidationErrors()); 84 | } 85 | 86 | public function testSuccessfulValidation() { 87 | $simple = Model::factory('Simple')->create( 88 | array('name'=>'Steve', 'age'=>'16') 89 | ); 90 | 91 | $this->assertTrue( 92 | $simple->validateProperty('name', $simple->name) 93 | ); // Success, because no validation assigned to 'name' 94 | 95 | // Using the default validation on age 96 | 97 | try { 98 | $simple->save(); 99 | } catch (\Sudzy\ValidationException $e) { 100 | $this->fail('ValidationException raised.'); 101 | } 102 | // Success! 103 | } 104 | 105 | public function testValidationOfNewModel() { 106 | $simple = Model::factory('Simple')->create( 107 | ['name'=>'Steve', 'age'=>'unknown'] 108 | ); 109 | 110 | $options = $simple->getValidationOptions(); 111 | $this->assertEquals($options['throw'], Sudzy\ValidModel::VALIDATE_ON_SAVE); 112 | 113 | // Use the default validation on age 114 | 115 | $this->expectException(Sudzy\ValidationException::class); 116 | $simple->save(); 117 | 118 | } 119 | 120 | public function testValidationMessageResetOnSet() { 121 | $simple = Model::factory('Simple')->create( 122 | array('name'=>'Steve', 'age'=>'0') 123 | ); 124 | $simple->setValidation('age', v::intVal()->positive()); 125 | 126 | $this->assertEmpty($simple->getValidationErrors()); 127 | 128 | $simple->age = null; 129 | $this->assertNotEmpty($simple->getValidationErrors()); 130 | 131 | $simple->age = 25; 132 | $this->assertEmpty($simple->getValidationErrors()); 133 | } 134 | 135 | public function testRemoveValidation() { 136 | $simple = Model::factory('Simple')->create( 137 | array('name'=>'Steve', 'age'=>'16') 138 | ); 139 | $this->assertEmpty($simple->getValidationErrors()); 140 | 141 | $this->assertNotEmpty($simple->getAllValidations()); 142 | 143 | //remove the default validation on age 144 | $simple->setValidation('age', null); 145 | 146 | $this->assertNull( 147 | $simple->getValidation('age') 148 | ); 149 | 150 | $this->assertEmpty($simple->getAllValidations()); 151 | 152 | $simple->age = 'not a valid age'; 153 | 154 | $this->assertEmpty($simple->getValidationErrors()); 155 | 156 | } 157 | 158 | public function testGetAndOverwriteValidations() { 159 | $simple = Model::factory('Simple')->find_one(1); 160 | 161 | $validation = $simple->getValidation('email'); 162 | $this->assertNull($validation); 163 | 164 | $simple->setValidation('email', v::notEmpty()->email() ); 165 | 166 | $this->assertNotNull($simple->getValidation('email')); 167 | } 168 | 169 | public function testThrowNativeRespectException() { 170 | $simple = Model::factory('Simple')->create( 171 | array('name'=>'Steve', 'age'=>'16') 172 | ); 173 | 174 | $this->expectException(Respect\Validation\Exceptions\NestedValidationException::class); 175 | 176 | $simple->validateProperty('age', 'not a valid age', true); 177 | } 178 | 179 | public function testNoExceptions() { 180 | $simple = Model::factory('Simple')->create( 181 | array('name'=>'Steve', 'age'=>'16') 182 | ); 183 | 184 | $simple->setValidationOptions([ 185 | 'throw' => $simple::VALIDATE_NEVER 186 | ]); 187 | 188 | $simple->age = 'not a valid age'; 189 | try { 190 | $simple->save(); 191 | } catch (\Sudzy\ValidationException $e) { 192 | $this->fail('ValidationException raised incorrectly.'); 193 | } 194 | $this->assertNotEmpty($simple->getValidationErrors()); 195 | $this->assertNotEmpty($simple->getValidationExceptions()); 196 | } 197 | } --------------------------------------------------------------------------------