Related
I have PostController, here is its method store().
public function store(Request $request)
{
$this->handleUploadedImage(
$request->file('upload'),
$request->input('CKEditorFuncNum')
);
$post = Post::create([
'content' => request('content'),
'is_published' => request('is_published'),
'slug' => Carbon::now()->format('Y-m-d-His'),
'title' => $this->firstSentence(request('content')),
'template' => $this->randomTemplate(request('template')),
]);
$post->tag(explode(',', $request->tags));
return redirect()->route('posts');
}
Method handleUploadedImage() now stored in PostController itself. But I'm going to use it in other controllers. Where should I move it? Not in Request class, because it's not about validation. Not in Models/Post, because it's not only for Post model. And it's not so global function for Service Provider class.
Methods firstSentence() and randomTemplate() are stored in that controller too. They will only be used in it. Maybe I should move them in Models/Post? In that way, how exactly call them in method store() (more specifically, in method create())?
I read the theory, and I understand (hopefully) the Concept of Thin Controllers and Fat Models, but I need some practical concrete advice with this example. Could you please suggest, where to move and how to call these methods?
First, a note: I don't work with Laravel, so I'll show you a general solution, pertinent to all frameworks.
Indeed, the controllers should always be kept thin. But this should be appliable to the model layer as well. Both goals are achievable by moving application-specific logic into application services (so, not into the model layer, making the models fat!). They are the components of the so-called service layer. Read this as well.
In your case, it seems that you can elegantly push the handling logic for uploaded images into a service, like App\Service\Http\Upload\ImageHandler, for example, containing a handle method. The names of the class and method could be chosen better, though, dependent on the exact class responsibility.
The logic for creating and storing a post would go into another application service: App\Service\Post, for example. In principle, this service would perform the following tasks:
Create an entity - Domain\Model\Post\Post, for example - and set its properties (title, content, is_published, template, etc) based on the user input. This could be done in a method App\Service\Post::createPost, for example.
Store the entity in the database, as a database record. This could be done in a method App\Service\Post::storePost, for example.
Other tasks...
In regard of the first task, two methods of the App\Service\Post service could be useful:
generatePostTitle, encapsulating the logic of extracting the first sentence from the user-provided "content", in order to set the title of the post entity from it;
generatePostTemplate, containing the logic described by you in the comment in regard of randomTemplate().
In regard of the second task, personally, I would store the entity in the database by using a specific data mapper - to directly communicate with the database API - and a specific repository on top of it - as abstraction of a collection of post objects.
Service:
<?php
namespace App\Service;
use Carbon;
use Domain\Model\Post\Post;
use Domain\Model\Post\PostCollection;
/**
* Post service.
*/
class Post {
/**
* Post collection.
*
* #var PostCollection
*/
private $postCollection;
/**
* #param PostCollection $postCollection Post collection.
*/
public function __construct(PostCollection $postCollection) {
$this->postCollection = $postCollection;
}
/**
* Create a post.
*
* #param string $content Post content.
* #param string $template The template used for the post.
* #param bool $isPublished (optional) Indicate if the post is published.
* #return Post Post.
*/
public function createPost(
string $content,
string $template,
bool $isPublished
/* , ... */
) {
$title = $this->generatePostTitle($content);
$slug = $this->generatePostSlug();
$template = $this->generatePostTemplate($template);
$post = new Post();
$post
->setTitle($title)
->setContent($content)
->setIsPublished($isPublished ? 1 : 0)
->setSlug($slug)
->setTemplate($template)
;
return $post;
}
/**
* Store a post.
*
* #param Post $post Post.
* #return Post Post.
*/
public function storePost(Post $post) {
return $this->postCollection->storePost($post);
}
/**
* Generate the title of a post by extracting
* a certain part from the given post content.
*
* #return string Generated post title.
*/
private function generatePostTitle(string $content) {
return substr($content, 0, 300) . '...';
}
/**
* Generate the slug of a post.
*
* #return string Generated slug.
*/
private function generatePostSlug() {
return Carbon::now()->format('Y-m-d-His');
}
/**
* Generate the template assignable to
* a post based on the given template.
*
* #return string Generated post template.
*/
private function generatePostTemplate(string $template) {
return 'the-generated-template';
}
}
Repository interface:
<?php
namespace Domain\Model\Post;
use Domain\Model\Post\Post;
/**
* Post collection interface.
*/
interface PostCollection {
/**
* Store a post.
*
* #param Post $post Post.
* #return Post Post.
*/
public function storePost(Post $post);
/**
* Find a post by id.
*
* #param int $id Post id.
* #return Post|null Post.
*/
public function findPostById(int $id);
/**
* Find all posts.
*
* #return Post[] Post list.
*/
public function findAllPosts();
/**
* Check if the given post exists.
*
* #param Post $post Post.
* #return bool True if post exists, false otherwise.
*/
public function postExists(Post $post);
}
Repository implementation:
<?php
namespace Domain\Infrastructure\Repository\Post;
use Domain\Model\Post\Post;
use Domain\Infrastructure\Mapper\Post\PostMapper;
use Domain\Model\Post\PostCollection as PostCollectionInterface;
/**
* Post collection.
*/
class PostCollection implements PostCollectionInterface {
/**
* Posts list.
*
* #var Post[]
*/
private $posts;
/**
* Post mapper.
*
* #var PostMapper
*/
private $postMapper;
/**
* #param PostMapper $postMapper Post mapper.
*/
public function __construct(PostMapper $postMapper) {
$this->postMapper = $postMapper;
}
/**
* Store a post.
*
* #param Post $post Post.
* #return Post Post.
*/
public function storePost(Post $post) {
$savedPost = $this->postMapper->savePost($post);
$this->posts[$savedPost->getId()] = $savedPost;
return $savedPost;
}
/**
* Find a post by id.
*
* #param int $id Post id.
* #return Post|null Post.
*/
public function findPostById(int $id) {
//...
}
/**
* Find all posts.
*
* #return Post[] Post list.
*/
public function findAllPosts() {
//...
}
/**
* Check if the given post exists.
*
* #param Post $post Post.
* #return bool True if post exists, false otherwise.
*/
public function postExists(Post $post) {
//...
}
}
Data mapper interface:
<?php
namespace Domain\Infrastructure\Mapper\Post;
use Domain\Model\Post\Post;
/**
* Post mapper.
*/
interface PostMapper {
/**
* Save a post.
*
* #param Post $post Post.
* #return Post Post entity with id automatically assigned upon persisting.
*/
public function savePost(Post $post);
/**
* Fetch a post by id.
*
* #param int $id Post id.
* #return Post|null Post.
*/
public function fetchPostById(int $id);
/**
* Fetch all posts.
*
* #return Post[] Post list.
*/
public function fetchAllPosts();
/**
* Check if a post exists.
*
* #param Post $post Post.
* #return bool True if the post exists, false otherwise.
*/
public function postExists(Post $post);
}
Data mapper PDO implementation:
<?php
namespace Domain\Infrastructure\Mapper\Post;
use PDO;
use Domain\Model\Post\Post;
use Domain\Infrastructure\Mapper\Post\PostMapper;
/**
* PDO post mapper.
*/
class PdoPostMapper implements PostMapper {
/**
* Database connection.
*
* #var PDO
*/
private $connection;
/**
* #param PDO $connection Database connection.
*/
public function __construct(PDO $connection) {
$this->connection = $connection;
}
/**
* Save a post.
*
* #param Post $post Post.
* #return Post Post entity with id automatically assigned upon persisting.
*/
public function savePost(Post $post) {
/*
* If $post->getId() is set, then call $this->updatePost()
* to update the existing post record in the database.
* Otherwise call $this->insertPost() to insert a new
* post record in the database.
*/
// ...
}
/**
* Fetch a post by id.
*
* #param int $id Post id.
* #return Post|null Post.
*/
public function fetchPostById(int $id) {
//...
}
/**
* Fetch all posts.
*
* #return Post[] Post list.
*/
public function fetchAllPosts() {
//...
}
/**
* Check if a post exists.
*
* #param Post $post Post.
* #return bool True if the post exists, false otherwise.
*/
public function postExists(Post $post) {
//...
}
/**
* Update an existing post.
*
* #param Post $post Post.
* #return Post Post entity with the same id upon updating.
*/
private function updatePost(Post $post) {
// Persist using SQL and PDO statements...
}
/**
* Insert a post.
*
* #param Post $post Post.
* #return Post Post entity with id automatically assigned upon persisting.
*/
private function insertPost(Post $post) {
// Persist using SQL and PDO statements...
}
}
In the end, your controller would look something like bellow. By reading its code, its role becomes obvious: just to push the user input to the service layer. The use of a service layer provides the big advantage of reusability.
<?php
namespace App\Controller;
use App\Service\Post;
use App\Service\Http\Upload\ImageHandler;
class PostController {
private $imageHandler;
private $postService;
public function __construct(ImageHandler $imageHandler, Post $postService) {
$this->imageHandler = $imageHandler;
$this->postService = $postService;
}
public function storePost(Request $request) {
$this->imageHandler->handle(
$request->file('upload'),
$request->input('CKEditorFuncNum')
);
$post = $this->postService->createPost(
request('content'),
request('template'),
request('is_published')
/* , ... */
);
return redirect()->route('posts');
}
}
PS: Keep in mind, that the model layer MUST NOT know anything about where its data is coming from, nor about how the data passed to it was created. So, the model layer must not know anything about a browser, or a request, or a controller, or a view, or a response, or etc. It just receives primitive values, objects, or DTOs ("data transfer objects") as arguments - see repository and data mapper above, for example.
PS 2: Note that a lot of frameworks are talking about repositories, but, in fact, they are talking about data mappers. My suggestion is to follow Fowler's conventions in your mind and your code. So, create data mappers in order to directly access a persistence space (database, filesystem, etc). If your project becomes more complex, or if you just want to have collection-like abstractions, then you can add a new layer of abstraction on top of the mappers: the repositories.
Resources
Keynote: Architecture the Lost Years by Robert C. Martin
Sandro Mancuso : Crafted Design
Clean, high quality code...
And a good 4-parts series of Gervasio on sitepoint.com:
Building a Domain Model - An Introduction to Persistence Agnosticism
Building a Domain Model – Integrating Data Mappers
Handling Collections of Aggregate Roots – the Repository Pattern
An Introduction to Services
What i usually do
public function store(ValidationRequest $request)
{
$result = $this->dispatchNow($request->validated())
return redirect()->route('posts');
}
So, i create a job for handling those registration steps, and i can reuse that in another parts of my system.
Your firstSentence i would move to an helper called strings app\helpers\strings (and don't forget to update that in composer.json and you could use just firstSentence($var) in any part of your system
The randomTemplate would fit nicelly in a trait, but i don't know what this methods does.
I am trying to do something that seems to go out of the box with how laravel-nova works ...
I have a Batch model/ressource that is used by super admins. Those batch reeports belongs to sevral merchants. We decided to add a layer of connection to are portal and allow merchants to log in and see there data. So obviously, when the merchant visites the batch repport page, he needs to see only data related to it's own account.
So what we did was add the merchant id inside the batch page like this:
nova/resources/batch?mid=0123456789
The problem we then found out is that the get param is not send to the page it self but in a subpage called filter ... so we hacked it and found a way to retreive it like this:
preg_match('/mid\=([0-9]{10})/', $_SERVER['HTTP_REFERER'], $matches);
Now that we have the mid, all we need to do is add a where() to the model but it's not working.
Obviously, this appoach is not the right way ... so my question is not how to make this code work ... but how to approche this to make it so that merchants can only see his own stuff when visiting a controller.
All i really need to is add some sort of a where('external_mid', '=' $mid) and everything is good.
The full code looks like this right now:
<?php
namespace App\Nova;
use App\Nova\Resource;
use Laravel\Nova\Fields\ID;
use Illuminate\Http\Request;
use Laravel\Nova\Fields\Text;
use Laravel\Nova\Fields\HasMany;
use Laravel\Nova\Fields\Currency;
use Laravel\Nova\Fields\BelongsTo;
use App\Nova\Filters\StatementDate;
use Laravel\Nova\Http\Requests\NovaRequest;
class Batch extends Resource
{
/**
* The model the resource corresponds to.
*
* #var string
*/
//
public static function query(){
preg_match('/mid\=([0-9]{10})/', $_SERVER['HTTP_REFERER'], $matches);
if (isset($matches['1'])&&$matches['1']!=''){
$model = \App\Batch::where('external_mid', '=', $matches['1']);
}else{
$model = \App\Batch::class;
}
return $model;
}
public static $model = $this->query();
/**
* The single value that should be used to represent the resource when being displayed.
*
* #var string
*/
public static $title = 'id';
/**
* The columns that should be searched.
*
* #var array
*/
public static $search = [
'id','customer_name', 'external_mid', 'merchant_id', 'batch_reference', 'customer_batch_reference',
'batch_amt', 'settlement_date', 'fund_amt', 'payment_reference', 'payment_date'
];
/**
* Indicates if the resource should be globally searchable.
*
* #var bool
*/
public static $globallySearchable = false;
/**
* Get the fields displayed by the resource.
*
* #param \Illuminate\Http\Request $request
* #return array
*/
public function fields(Request $request)
{
return [
ID::make()->hideFromIndex(),
Text::make('Customer','customer_name'),
Text::make('MID','external_mid'),
Text::make('Batch Ref #','batch_reference'),
Text::make('Batch ID','customer_batch_reference'),
Text::make('Batch Date','settlement_date')->sortable(),
Currency::make('Batch Amount','batch_amt'),
Text::make('Funding Reference','payment_reference')->hideFromIndex(),
Text::make('Funding Date','payment_date')->hideFromIndex(),
Currency::make('Funding Amount','fund_amt')->hideFromIndex(),
// **Relationships**
HasMany::make('Transactions'),
BelongsTo::make('Merchant')->hideFromIndex(),
// ***
];
}
/**
* Get the cards available for the request.
*
* #param \Illuminate\Http\Request $request
* #return array
*/
public function cards(Request $request)
{
return [];
}
/**
* Get the filters available for the resource.
*
* #param \Illuminate\Http\Request $request
* #return array
*/
public function filters(Request $request)
{
return [
];
}
/**
* Get the lenses available for the resource.
*
* #param \Illuminate\Http\Request $request
* #return array
*/
public function lenses(Request $request)
{
return [];
}
/**
* Get the actions available for the resource.
*
* #param \Illuminate\Http\Request $request
* #return array
*/
public function actions(Request $request)
{
return [];
}
}
In Laravel Nova you can modify the result query of any Resource by adding the index Query method. This method allows you to use Eloquent to modify the results with any condition you define.
I understand you just need to maintain the $model property with the model with the default definition and modify the results in the indexQuery method:
...
public static $model = \App\Batch::class;
public static function indexQuery(NovaRequest $request, $query)
{
// Using the same logic of the example above. I recommend to use the $request variable to access data instead of the $_SERVER global variable.
preg_match('/mid\=([0-9]{10})/', $_SERVER['HTTP_REFERER'], $matches);
if (isset($matches['1'])&&$matches['1']!=''){
return $query->where('external_mid', '=', $matches['1']);
}else{
return $query;
}
}
...
About the use of the PHP Global Variable, I recommend you to use the laravel default request() to look into your URL. You can use something like this $request->mid to read the value from the mid value in the URL.
I'm trying to update multiple models with a directive, but the current #update directive does not support multiple ids. I would basically want the #delete directive (where you can use a list of ids). To update multiple models. I'm guessing I could create a custom directive, but it's alot of code there that I can't wrap my head around. I've tried to read the docs to understand how to create a custom directive, but I can't get it to work.
So the DeleteDirective.php got this:
/**
* Bring a model in or out of existence.
*
* #param \Illuminate\Database\Eloquent\Model $model
* #return void
*/
protected function modifyExistence(Model $model): void
{
$model->delete();
}
And I would basically want this (for multiple ids):
/**
* Update a model to read true
*
* #param \Illuminate\Database\Eloquent\Model $model
* #return void
*/
protected function updateRead(Model $model): void
{
$model->update(['read' => true]);
}
By defining a mutation query like this:
type Mutation {
updatePostsToRead(id: [ID!]!): [Post!]! #updateRead
}
And doing a query like this:
{
mutation {
updatePostsToRead(id: [6,8]) {
id
amount
}
}
}
Does anyone know how I would go by doing this? Or can point me in the right direction?
Found a way to do it without creating a custom directive. Just made a custom mutation with php artisan lighthouse:mutation updatePostsToRead.
updatePostsToRead.php:
class updatePostsToRead
{
/**
* Return a value for the field.
*
* #param null $rootValue Usually contains the result returned from the parent field. In this case, it is always `null`.
* #param mixed[] $args The arguments that were passed into the field.
* #param \Nuwave\Lighthouse\Support\Contracts\GraphQLContext $context Arbitrary data that is shared between all fields of a single query.
* #param \GraphQL\Type\Definition\ResolveInfo $resolveInfo Information about the query itself, such as the execution state, the field name, path to the field from the root, and more.
* #return mixed
*/
public function __invoke(
$rootValue,
array $args,
GraphQLContext $context,
ResolveInfo $resolveInfo
) {
// TODO implement the resolver
\DB::table('posts')
->whereIn('id', $args["ids"])
->update(['read' => true]);
$posts = Post::whereIn('id', $args["ids"])->get();
return $posts;
}
}
Schema:
type Mutation {
updatePostsToRead(ids: [ID]): [Post]
}
Query on client:
mutation{
updatePostsToRead(ids: [2,6,8]) {
id
description
read
}
}
<?php
/**
* #link http://www.yiiframework.com/
* #copyright Copyright (c) 2008 Yii Software LLC
* #license http://www.yiiframework.com/license/
*/
namespace yii\data;
use Yii;
use yii\base\Component;
use yii\base\InvalidParamException;
/**
* BaseDataProvider provides a base class that implements the [[DataProviderInterface]].
*
* #property integer $count The number of data models in the current page. This property is read-only.
* #property array $keys The list of key values corresponding to [[models]]. Each data model in [[models]] is
* uniquely identified by the corresponding key value in this array.
* #property array $models The list of data models in the current page.
* #property Pagination|boolean $pagination The pagination object. If this is false, it means the pagination
* is disabled. Note that the type of this property differs in getter and setter. See [[getPagination()]] and
* [[setPagination()]] for details.
* #property Sort|boolean $sort The sorting object. If this is false, it means the sorting is disabled. Note
* that the type of this property differs in getter and setter. See [[getSort()]] and [[setSort()]] for details.
* #property integer $totalCount Total number of possible data models.
*
* #author Qiang Xue <qiang.xue#gmail.com>
* #since 2.0
*/
abstract class BaseDataProvider extends Component implements DataProviderInterface
{
/**
* #var string an ID that uniquely identifies the data provider among all data providers.
* You should set this property if the same page contains two or more different data providers.
* Otherwise, the [[pagination]] and [[sort]] may not work properly.
*/
public $id;
private $_sort;
private $_pagination;
private $_keys;
private $_models;
private $_totalCount;
/**
* Prepares the data models that will be made available in the current page.
* #return array the available data models
*/
abstract protected function prepareModels();
/**
* Prepares the keys associated with the currently available data models.
* #param array $models the available data models
* #return array the keys
*/
abstract protected function prepareKeys($models);
/**
* Returns a value indicating the total number of data models in this data provider.
* #return integer total number of data models in this data provider.
*/
abstract protected function prepareTotalCount();
/**
* Prepares the data models and keys.
*
* This method will prepare the data models and keys that can be retrieved via
* [[getModels()]] and [[getKeys()]].
*
* This method will be implicitly called by [[getModels()]] and [[getKeys()]] if it has not been called before.
*
* #param boolean $forcePrepare whether to force data preparation even if it has been done before.
*/
public function prepare($forcePrepare = false)
{
if ($forcePrepare || $this->_models === null) {
$this->_models = $this->prepareModels();
}
if ($forcePrepare || $this->_keys === null) {
$this->_keys = $this->prepareKeys($this->_models);
}
}
/**
* Returns the data models in the current page.
* #return array the list of data models in the current page.
*/
public function getModels()
{
$this->prepare();
return $this->_models;
}
/**
* Sets the data models in the current page.
* #param array $models the models in the current page
*/
public function setModels($models)
{
$this->_models = $models;
}
/**
* Returns the key values associated with the data models.
* #return array the list of key values corresponding to [[models]]. Each data model in [[models]]
* is uniquely identified by the corresponding key value in this array.
*/
public function getKeys()
{
$this->prepare();
return $this->_keys;
}
/**
* Sets the key values associated with the data models.
* #param array $keys the list of key values corresponding to [[models]].
*/
public function setKeys($keys)
{
$this->_keys = $keys;
}
/**
* Returns the number of data models in the current page.
* #return integer the number of data models in the current page.
*/
public function getCount()
{
return count($this->getModels());
}
/**
* Returns the total number of data models.
* When [[pagination]] is false, this returns the same value as [[count]].
* Otherwise, it will call [[prepareTotalCount()]] to get the count.
* #return integer total number of possible data models.
*/
public function getTotalCount()
{
if ($this->getPagination() === false) {
return $this->getCount();
} elseif ($this->_totalCount === null) {
$this->_totalCount = $this->prepareTotalCount();
}
return $this->_totalCount;
}
/**
* Sets the total number of data models.
* #param integer $value the total number of data models.
*/
public function setTotalCount($value)
{
$this->_totalCount = $value;
}
/**
* Returns the pagination object used by this data provider.
* Note that you should call [[prepare()]] or [[getModels()]] first to get correct values
* of [[Pagination::totalCount]] and [[Pagination::pageCount]].
* #return Pagination|boolean the pagination object. If this is false, it means the pagination is disabled.
*/
public function getPagination()
{
if ($this->_pagination === null) {
$this->setPagination([]);
}
return $this->_pagination;
}
/**
* Sets the pagination for this data provider.
* #param array|Pagination|boolean $value the pagination to be used by this data provider.
* This can be one of the following:
*
* - a configuration array for creating the pagination object. The "class" element defaults
* to 'yii\data\Pagination'
* - an instance of [[Pagination]] or its subclass
* - false, if pagination needs to be disabled.
*
* #throws InvalidParamException
*/
public function setPagination($value)
{
if (is_array($value)) {
$config = ['class' => Pagination::className()];
if ($this->id !== null) {
$config['pageParam'] = $this->id . '-page';
$config['pageSizeParam'] = $this->id . '-per-page';
}
$this->_pagination = Yii::createObject(array_merge($config, $value));
} elseif ($value instanceof Pagination || $value === false) {
$this->_pagination = $value;
} else {
throw new InvalidParamException('Only Pagination instance, configuration array or false is allowed.');
}
}
/**
* Returns the sorting object used by this data provider.
* #return Sort|boolean the sorting object. If this is false, it means the sorting is disabled.
*/
public function getSort()
{
if ($this->_sort === null) {
$this->setSort([]);
}
return $this->_sort;
}
/**
* Sets the sort definition for this data provider.
* #param array|Sort|boolean $value the sort definition to be used by this data provider.
* This can be one of the following:
*
* - a configuration array for creating the sort definition object. The "class" element defaults
* to 'yii\data\Sort'
* - an instance of [[Sort]] or its subclass
* - false, if sorting needs to be disabled.
*
* #throws InvalidParamException
*/
public function setSort($value)
{
if (is_array($value)) {
$config = ['class' => Sort::className()];
if ($this->id !== null) {
$config['sortParam'] = $this->id . '-sort';
}
$this->_sort = Yii::createObject(array_merge($config, $value));
} elseif ($value instanceof Sort || $value === false) {
$this->_sort = $value;
} else {
throw new InvalidParamException('Only Sort instance, configuration array or false is allowed.');
}
}
/**
* Refreshes the data provider.
* After calling this method, if [[getModels()]], [[getKeys()]] or [[getTotalCount()]] is called again,
* they will re-execute the query and return the latest data available.
*/
public function refresh()
{
$this->_totalCount = null;
$this->_models = null;
$this->_keys = null;
}
}
The code above is the BaseDataProvider for yii2. My question is how i can set the _models and _keys in yii2? Which file do i need to change to link to that? Sorry i am quite new to yii. Please provide an example if possible thank you.
That what's You pasted here is abstract Yii2 class, which You should NEVER edit.
To use this thing i suggest You to read about ActiveDataProvider here: Docs
$query = Post::find()->where(['status' => 1]);
$provider = new ActiveDataProvider([
'query' => $query,
]);
Here's an example how to use it, first line defines data which will be used to populate ActiveDataProvider (it's a SQL query), and then You create ActiveDataProvider instance with query as config parameter.
I'm quite new to Zend and unit testing in general. I have come up with a small application that uses Zend Framework 2 and Doctrine. It has only one model and controller and I want to run some unit tests on them.
Here's what I have so far:
Base doctrine 'entity' class, containing methods I want to use in all of my entities:
<?php
/**
* Base entity class containing some functionality that will be used by all
* entities
*/
namespace Perceptive\Database;
use Zend\Validator\ValidatorChain;
class Entity{
//An array of validators for various fields in this entity
protected $validators;
/**
* Returns the properties of this object as an array for ease of use. Will
* return only properties with the ORM\Column annotation as this way we know
* for sure that it is a column with data associated, and won't pick up any
* other properties.
* #return array
*/
public function toArray(){
//Create an annotation reader so we can read annotations
$reader = new \Doctrine\Common\Annotations\AnnotationReader();
//Create a reflection class and retrieve the properties
$reflClass = new \ReflectionClass($this);
$properties = $reflClass->getProperties();
//Create an array in which to store the data
$array = array();
//Loop through each property. Get the annotations for each property
//and add to the array to return, ONLY if it contains an ORM\Column
//annotation.
foreach($properties as $property){
$annotations = $reader->getPropertyAnnotations($property);
foreach($annotations as $annotation){
if($annotation instanceof \Doctrine\ORM\Mapping\Column){
$array[$property->name] = $this->{$property->name};
}
}
}
//Finally, return the data array to the user
return $array;
}
/**
* Updates all of the values in this entity from an array. If any property
* does not exist a ReflectionException will be thrown.
* #param array $data
* #return \Perceptive\Database\Entity
*/
public function fromArray($data){
//Create an annotation reader so we can read annotations
$reader = new \Doctrine\Common\Annotations\AnnotationReader();
//Create a reflection class and retrieve the properties
$reflClass = new \ReflectionClass($this);
//Loop through each element in the supplied array
foreach($data as $key=>$value){
//Attempt to get at the property - if the property doesn't exist an
//exception will be thrown here.
$property = $reflClass->getProperty($key);
//Access the property's annotations
$annotations = $reader->getPropertyAnnotations($property);
//Loop through all annotations to see if this is actually a valid column
//to update.
$isColumn = false;
foreach($annotations as $annotation){
if($annotation instanceof \Doctrine\ORM\Mapping\Column){
$isColumn = true;
}
}
//If it is a column then update it using it's setter function. Otherwise,
//throw an exception.
if($isColumn===true){
$func = 'set'.ucfirst($property->getName());
$this->$func($data[$property->getName()]);
}else{
throw new \Exception('You cannot update the value of a non-column using fromArray.');
}
}
//return this object to facilitate a 'fluent' interface.
return $this;
}
/**
* Validates a field against an array of validators. Returns true if the value is
* valid or an error string if not.
* #param string $fieldName The name of the field to validate. This is only used when constructing the error string
* #param mixed $value
* #param array $validators
* #return boolean|string
*/
protected function setField($fieldName, $value){
//Create a validator chain
$validatorChain = new ValidatorChain();
$validators = $this->getValidators();
//Try to retrieve the validators for this field
if(array_key_exists($fieldName, $this->validators)){
$validators = $this->validators[$fieldName];
}else{
$validators = array();
}
//Add all validators to the chain
foreach($validators as $validator){
$validatorChain->attach($validator);
}
//Check if the value is valid according to the validators. Return true if so,
//or an error string if not.
if($validatorChain->isValid($value)){
$this->{$fieldName} = $value;
return $this;
}else{
$err = 'The '.$fieldName.' field was not valid: '.implode(',',$validatorChain->getMessages());
throw new \Exception($err);
}
}
}
My 'config' entity, which represents a one-row table containing some configuration options:
<?php
/**
* #todo: add a base entity class which handles validation via annotations
* and includes toArray function. Also needs to get/set using __get and __set
* magic methods. Potentially add a fromArray method?
*/
namespace Application\Entity;
use Doctrine\ORM\Mapping as ORM;
use Zend\Validator;
use Zend\I18n\Validator as I18nValidator;
use Perceptive\Database\Entity;
/**
* #ORM\Entity
* #ORM\HasLifecycleCallbacks
*/
class Config extends Entity{
/**
* #ORM\Id
* #ORM\Column(type="integer")
*/
protected $minLengthUserId;
/**
* #ORM\Id
* #ORM\Column(type="integer")
*/
protected $minLengthUserName;
/**
* #ORM\Id
* #ORM\Column(type="integer")
*/
protected $minLengthUserPassword;
/**
* #ORM\Id
* #ORM\Column(type="integer")
*/
protected $daysPasswordReuse;
/**
* #ORM\Id
* #ORM\Column(type="boolean")
*/
protected $passwordLettersAndNumbers;
/**
* #ORM\Id
* #ORM\Column(type="boolean")
*/
protected $passwordUpperLower;
/**
* #ORM\Id
* #ORM\Column(type="integer")
*/
protected $maxFailedLogins;
/**
* #ORM\Id
* #ORM\Column(type="integer")
*/
protected $passwordValidity;
/**
* #ORM\Id
* #ORM\Column(type="integer")
*/
protected $passwordExpiryDays;
/**
* #ORM\Id
* #ORM\Column(type="integer")
*/
protected $timeout;
// getters/setters
/**
* Get the minimum length of the user ID
* #return int
*/
public function getMinLengthUserId(){
return $this->minLengthUserId;
}
/**
* Set the minmum length of the user ID
* #param int $minLengthUserId
* #return \Application\Entity\Config This object
*/
public function setMinLengthUserId($minLengthUserId){
//Use the setField function, which checks whether the field is valid,
//to set the value.
return $this->setField('minLengthUserId', $minLengthUserId);
}
/**
* Get the minimum length of the user name
* #return int
*/
public function getminLengthUserName(){
return $this->minLengthUserName;
}
/**
* Set the minimum length of the user name
* #param int $minLengthUserName
* #return \Application\Entity\Config
*/
public function setMinLengthUserName($minLengthUserName){
//Use the setField function, which checks whether the field is valid,
//to set the value.
return $this->setField('minLengthUserName', $minLengthUserName);
}
/**
* Get the minimum length of the user password
* #return int
*/
public function getMinLengthUserPassword(){
return $this->minLengthUserPassword;
}
/**
* Set the minimum length of the user password
* #param int $minLengthUserPassword
* #return \Application\Entity\Config
*/
public function setMinLengthUserPassword($minLengthUserPassword){
//Use the setField function, which checks whether the field is valid,
//to set the value.
return $this->setField('minLengthUserPassword', $minLengthUserPassword);
}
/**
* Get the number of days before passwords can be reused
* #return int
*/
public function getDaysPasswordReuse(){
return $this->daysPasswordReuse;
}
/**
* Set the number of days before passwords can be reused
* #param int $daysPasswordReuse
* #return \Application\Entity\Config
*/
public function setDaysPasswordReuse($daysPasswordReuse){
//Use the setField function, which checks whether the field is valid,
//to set the value.
return $this->setField('daysPasswordReuse', $daysPasswordReuse);
}
/**
* Get whether the passwords must contain letters and numbers
* #return boolean
*/
public function getPasswordLettersAndNumbers(){
return $this->passwordLettersAndNumbers;
}
/**
* Set whether passwords must contain letters and numbers
* #param int $passwordLettersAndNumbers
* #return \Application\Entity\Config
*/
public function setPasswordLettersAndNumbers($passwordLettersAndNumbers){
//Use the setField function, which checks whether the field is valid,
//to set the value.
return $this->setField('passwordLettersAndNumbers', $passwordLettersAndNumbers);
}
/**
* Get whether password must contain upper and lower case characters
* #return type
*/
public function getPasswordUpperLower(){
return $this->passwordUpperLower;
}
/**
* Set whether password must contain upper and lower case characters
* #param type $passwordUpperLower
* #return \Application\Entity\Config
*/
public function setPasswordUpperLower($passwordUpperLower){
//Use the setField function, which checks whether the field is valid,
//to set the value.
return $this->setField('passwordUpperLower', $passwordUpperLower);
}
/**
* Get the number of failed logins before user is locked out
* #return int
*/
public function getMaxFailedLogins(){
return $this->maxFailedLogins;
}
/**
* Set the number of failed logins before user is locked out
* #param int $maxFailedLogins
* #return \Application\Entity\Config
*/
public function setMaxFailedLogins($maxFailedLogins){
//Use the setField function, which checks whether the field is valid,
//to set the value.
return $this->setField('maxFailedLogins', $maxFailedLogins);
}
/**
* Get the password validity period in days
* #return int
*/
public function getPasswordValidity(){
return $this->passwordValidity;
}
/**
* Set the password validity in days
* #param int $passwordValidity
* #return \Application\Entity\Config
*/
public function setPasswordValidity($passwordValidity){
//Use the setField function, which checks whether the field is valid,
//to set the value.
return $this->setField('passwordValidity', $passwordValidity);
}
/**
* Get the number of days prior to expiry that the user starts getting
* warning messages
* #return int
*/
public function getPasswordExpiryDays(){
return $this->passwordExpiryDays;
}
/**
* Get the number of days prior to expiry that the user starts getting
* warning messages
* #param int $passwordExpiryDays
* #return \Application\Entity\Config
*/
public function setPasswordExpiryDays($passwordExpiryDays){
//Use the setField function, which checks whether the field is valid,
//to set the value.
return $this->setField('passwordExpiryDays', $passwordExpiryDays);
}
/**
* Get the timeout period of the application
* #return int
*/
public function getTimeout(){
return $this->timeout;
}
/**
* Get the timeout period of the application
* #param int $timeout
* #return \Application\Entity\Config
*/
public function setTimeout($timeout){
//Use the setField function, which checks whether the field is valid,
//to set the value.
return $this->setField('timeout', $timeout);
}
/**
* Returns a list of validators for each column. These validators are checked
* in the class' setField method, which is inherited from the Perceptive\Database\Entity class
* #return array
*/
public function getValidators(){
//If the validators array hasn't been initialised, initialise it
if(!isset($this->validators)){
$validators = array(
'minLengthUserId' => array(
new I18nValidator\Int(),
new Validator\GreaterThan(1),
),
'minLengthUserName' => array(
new I18nValidator\Int(),
new Validator\GreaterThan(2),
),
'minLengthUserPassword' => array(
new I18nValidator\Int(),
new Validator\GreaterThan(3),
),
'daysPasswordReuse' => array(
new I18nValidator\Int(),
new Validator\GreaterThan(-1),
),
'passwordLettersAndNumbers' => array(
new I18nValidator\Int(),
new Validator\GreaterThan(-1),
new Validator\LessThan(2),
),
'passwordUpperLower' => array(
new I18nValidator\Int(),
new Validator\GreaterThan(-1),
new Validator\LessThan(2),
),
'maxFailedLogins' => array(
new I18nValidator\Int(),
new Validator\GreaterThan(0),
),
'passwordValidity' => array(
new I18nValidator\Int(),
new Validator\GreaterThan(1),
),
'passwordExpiryDays' => array(
new I18nValidator\Int(),
new Validator\GreaterThan(1),
),
'timeout' => array(
new I18nValidator\Int(),
new Validator\GreaterThan(0),
)
);
$this->validators = $validators;
}
//Return the list of validators
return $this->validators;
}
/**
* #todo: add a lifecyle event which validates before persisting the entity.
* This way there is no chance of invalid values being saved to the database.
* This should probably be implemented in the parent class so all entities know
* to validate.
*/
}
And my controller, which can read from and write to the entity:
<?php
/**
* A restful controller that retrieves and updates configuration information
*/
namespace Application\Controller;
use Zend\Mvc\Controller\AbstractRestfulController;
use Zend\View\Model\JsonModel;
class ConfigController extends AbstractRestfulController
{
/**
* The doctrine EntityManager for use with database operations
* #var \Doctrine\ORM\EntityManager
*/
protected $em;
/**
* Constructor function manages dependencies
* #param \Doctrine\ORM\EntityManager $em
*/
public function __construct(\Doctrine\ORM\EntityManager $em){
$this->em = $em;
}
/**
* Retrieves the configuration from the database
*/
public function getList(){
//locate the doctrine entity manager
$em = $this->em;
//there should only ever be one row in the configuration table, so I use findAll
$config = $em->getRepository("\Application\Entity\Config")->findAll();
//return a JsonModel to the user. I use my toArray function to convert the doctrine
//entity into an array - the JsonModel can't handle a doctrine entity itself.
return new JsonModel(array(
'data' => $config[0]->toArray(),
));
}
/**
* Updates the configuration
*/
public function replaceList($data){
//locate the doctrine entity manager
$em = $this->em;
//there should only ever be one row in the configuration table, so I use findAll
$config = $em->getRepository("\Application\Entity\Config")->findAll();
//use the entity's fromArray function to update the data
$config[0]->fromArray($data);
//save the entity to the database
$em->persist($config[0]);
$em->flush();
//return a JsonModel to the user. I use my toArray function to convert the doctrine
//entity into an array - the JsonModel can't handle a doctrine entity itself.
return new JsonModel(array(
'data' => $config[0]->toArray(),
));
}
}
Because of character limits on I was unable to paste in my unit tests, but here are links to my unit tests so far:
For the entity:
https://github.com/hputus/config-app/blob/master/module/Application/test/ApplicationTest/Entity/ConfigTest.php
For the controller:
https://github.com/hputus/config-app/blob/master/module/Application/test/ApplicationTest/Controller/ConfigControllerTest.php
Some questions:
Am I doing anything obviously wrong here?
In the tests for the entity, I am repeating the same tests for many different fields - is there a way to minimise this? Like have a standard battery of tests to run on integer columns for instance?
In the controller I am trying to 'mock up' doctrine's entity manager so that changes aren't really saved into the database - am I doing this properly?
Is there anything else in the controller which I should test?
Thanks in advance!
While your code appears to be solid enough, it presents a couple of design oversights.
First of all, Doctrine advise to treat entities like simple, dumb value objects, and states that the data they hold is always assumed to be valid.
This means that any business logic, like hydration, filtering and validation, should be moved outside entities to a separate layer.
Speaking of hydration, rather than implementing by yourself fromArray and toArray methods, you could use the supplied DoctrineModule\Stdlib\Hydrator\DoctrineObject hydrator, which can also blend flawlessly with Zend\InputFilter, to handle filtering and validation. This would make entity testing much much less verbose, and arguably not so needed, since you would test the filter separately.
Another important suggestion coming from Doctrine devs is to not inject an ObjectManager directly inside controllers. This is for encapsulation purposes: it is desirable to hide implementation details of your persistence layer to the Controller and, again, expose only an intermediate layer.
In your case, all this could be done by having a ConfigService class, designed by contract, which will only provide the methods you really need (i.e. findAll(), persist() and other handy proxies), and will hide the dependencies that are not strictly needed by the controller, like the EntityManager, input filters and the like. It will also contribute to easier mocking.
This way, if one day you would want to do some changes in your persistence layer, you would just have to change how your entity service implements its contract: think about adding a custom cache adapter, or using Doctrine's ODM rather than the ORM, or even not using Doctrine at all.
Other than that, your unit testing approach looks fine.
TL;DR
You should not embed business logic inside Doctrine entities.
You should use hydrators with input filters together.
You should not inject the EntityManager inside controllers.
An intermediate layer would help implementing these variations, preserving at the same time Model and Controller decoupling.
Your tests look very similar to ours, so there's nothing immediately obvious that you are doing incorrectly. :)
I agree that this "smells" a bit weird, but I don't have an answer for you on this one. Our standard is to make all of our models "dumb" and we do not test them. This is not something I recommend, but because I havent encountered your scenario before I don't want to just guess.
You seem to be testing pretty exhaustively, although I would really recommend checking out the mocking framework: Phake (http://phake.digitalsandwich.com/docs/html/) It really helps to seperate your assertions from your mocking, as well as provides a much more digestable syntax than the built in phpunit mocks.
good luck!