From f0624a1a087089638f9be4210e701cc9c65eec84 Mon Sep 17 00:00:00 2001 From: "Md. Touhidur Rahman" Date: Sat, 21 Aug 2021 23:10:52 +0600 Subject: [PATCH] initial commit --- .gitattributes | 0 .gitignore | 5 + .travis.yml | 17 + CHANGELOG.md | 8 + LICENSE.md | 21 + README.md | 318 ++++++++++++++ composer.json | 42 ++ config/model-repository.php | 42 ++ phpunit.xml.dist | 41 ++ src/BaseRepository.php | 408 ++++++++++++++++++ src/Console/Concerns/ClassFileResolver.php | 67 +++ .../Concerns/CommandExceptionHandler.php | 31 ++ src/Console/Concerns/StubGenerator.php | 222 ++++++++++ src/Console/Repository.php | 116 +++++ src/Console/stubs/repository.stub | 23 + src/Contracts/RepositoryContract.php | 55 +++ src/Facades/ModelRepository.php | 18 + src/ModelRepositoryServiceProvider.php | 41 ++ tests/App/Models/Profile.php | 27 ++ tests/App/Models/User.php | 27 ++ tests/App/Repositories/ProfileRepository.php | 23 + .../2014_10_12_000000_create_users_table.php | 33 ++ ...014_10_12_000001_create_profiles_table.php | 33 ++ tests/CommandTest.php | 55 +++ tests/RepositoryTest.php | 333 ++++++++++++++ tests/Traits/LaravelSetup.php | 49 +++ tests/Traits/LaravelTestBootstrapping.php | 36 ++ tests/config/model-repository.php | 42 ++ 28 files changed, 2133 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 .travis.yml create mode 100644 CHANGELOG.md create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 composer.json create mode 100644 config/model-repository.php create mode 100644 phpunit.xml.dist create mode 100644 src/BaseRepository.php create mode 100644 src/Console/Concerns/ClassFileResolver.php create mode 100644 src/Console/Concerns/CommandExceptionHandler.php create mode 100644 src/Console/Concerns/StubGenerator.php create mode 100644 src/Console/Repository.php create mode 100644 src/Console/stubs/repository.stub create mode 100644 src/Contracts/RepositoryContract.php create mode 100644 src/Facades/ModelRepository.php create mode 100644 src/ModelRepositoryServiceProvider.php create mode 100644 tests/App/Models/Profile.php create mode 100644 tests/App/Models/User.php create mode 100644 tests/App/Repositories/ProfileRepository.php create mode 100644 tests/App/database/migrations/2014_10_12_000000_create_users_table.php create mode 100644 tests/App/database/migrations/2014_10_12_000001_create_profiles_table.php create mode 100644 tests/CommandTest.php create mode 100644 tests/RepositoryTest.php create mode 100644 tests/Traits/LaravelSetup.php create mode 100644 tests/Traits/LaravelTestBootstrapping.php create mode 100644 tests/config/model-repository.php diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..e69de29 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1798484 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.DS_Store +/vendor +/build +composer.lock +.phpunit.result.cache \ No newline at end of file diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..297f785 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,17 @@ +language: php + +php: + - 7.3 + - 7.4 + - 8.0 + +env: + matrix: + - COMPOSER_FLAGS="--prefer-lowest" + - COMPOSER_FLAGS="" + +before_script: + - travis_retry composer update ${COMPOSER_FLAGS} + +script: + - vendor/bin/phpunit \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f37f1f0 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,8 @@ +# Changelog +All notable changes to this project will be documented in this file. + +## [Unreleased] + + +## [1.0.0] - 2021-08-21 +- Initial release \ No newline at end of file diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..4d2feef --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Touhidur Rahman Abir + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..298b8bc --- /dev/null +++ b/README.md @@ -0,0 +1,318 @@ +# Laravel Model UUID + +A simple package to use **Repository Pattern** approach for laravel models . + +## Repository pattern +Repositories are classes or components that encapsulate the logic required to access data sources. They centralize common data access functionality, providing better maintainability and decoupling the infrastructure or technology used to access databases from the domain model layer. [Microsoft](https://docs.microsoft.com/en-us/dotnet/architecture/microservices/microservice-ddd-cqrs-patterns/infrastructure-persistence-layer-design) + +## Installation + +Require the package using composer: + +```bash +composer require touhidurabir/laravel-model-repository +``` + +To publish the config file: +```bash +php artisan vendor:publish --provider="Touhidurabir\ModelRepository\ModelRepositoryServiceProvider" --tag=config +``` + +## Command and Configuration + +To use this package, you need to have repository class bound to laravel model class . This package includes a command that make it easy to to create repository classes from command line . to create a new repository class, run the following command + +```bash +php artisan make:repository UserRepository --model=User +``` + +The above command will create a new repository **UserRepository** class in **App\Repositories** path . the **--model** option to define which laravel model class to target for this repositoty class . The content of **UserRepository** will look like + +```php +namespace App\Repositories; + +use Touhidurabir\ModelRepository\BaseRepository; +use App\Models\User; + +class UserRepository extends BaseRepository { + + /** + * Constructor to bind model to repo + * + * @param object $user + * @return void + */ + public function __construct(User $user) { + + $this->model = $user; + + $this->modelClass = get_class($user); + } + +} +``` + +This package by default assume all **models** are located in path **App\Models** and use the path **App\Repositories** to store the **repository** classes. But also possible to provide custom repositories class path and different model class path . for example + +```bash +php artisan make:reposity App\\SomeOtherPath\\UserRepository --model=App\\OtherModelPath\\User +``` +The above command will try to store the repository class to path **App\SomeOtherPath** and will create a directory named **SomeOtherPath** if not already exists. Will also try to resolve model path/namespace from **App\OtherModelPath** . + +Check the **config** file after publishing at the **config/model-repository.php** to see the default settings configurations . + +## Usage + +The best way to use the repository classes via **Dependency Injection** through the **controller** classes . for example : + +```php +namespace App\Http\Controllers; + +use App\Http\Controllers\Controller; +use App\Repositories\UserRepository; + +class UserController extends Controller { + + /** + * The resource repository instance + * + * @var mixed + */ + protected $userRepository; + + /** + * create a new controller instance + * + * @param \App\Repositories\UserRepository $userRepository + * @return void + */ + public function __construct(UserRepository $userRepository) { + + $this->userRepository = $userRepository; + } +} +``` + +And in that way one can already get a fully qualified user repository class . Also to manually initiated : + +```php +namespace App\Http\Controllers; + +use App\Models\User; +use App\Http\Controllers\Controller; +use App\Repositories\UserRepository; +... + +$userRepository = new UserRepository(new User); +``` + +Or through static constructor +```php +$userRepository = UserRepository::withModel(new User); +``` + +The repository class will have following features/abilities . + +### Create + +To create a new model record, just call the **create** method on repositoty class and pass the data attributes as : + +```php +$this->userRepository->create([ + ... +]); +``` + +### Update + +To update a existing model record, call the **update** method of the repository class . the **update** method will require 2 params , the data attributes and the model redored primary key value or an exiting model instance . + +To update with primary key for user with primary key of id with value 10 + +```php +$primaryKeyValue = 10; + +$this->userRepository->update([ + ... +], $primaryKeyValue); +``` +or To update the already retrived model record : + +```php +$user; // the already retrived model record instance + +$this->userRepository->update([ + ... +], $user); +``` + +### Find + +To find a model record, use the **find** method of the repository class + +```php +$this->userRepository->find(1); // find the id(primary key) of 1 +$this->userRepository->find([1,2,3]); // find the id(primary key) of 1,2 and 3 +``` + +The **find** method can also work with array where it will use those as **AND WHERE** query and return the first record that match + +```php +$this->userRepository->find(['email' => 'somemail@mail.test']); +``` + +By passing the optional relations array as the second argument to **find** method will load the relations along with model record + +```php +$this->userRepository->find(1, ['profile']); // find the id(primary key) of 1 +$this->userRepository->find([1,2,3], ['profile']); // find the id(primary key) of 1,2 and 3 +``` + +The thrid agument is a optional boolen which is by default set to **false** . By setting it to **true**, it will thorw the **\Illuminate\Database\Eloquent\ModelNotFoundException** when a model record not found . + +```php +$this->userRepository->find(1, ['profile'], true); // find the id(primary key) of 1 +$this->userRepository->find([1,2,3], [], true); // find the id(primary key) of 1,2 and 3 +``` + +### All Records + +To get back all records, use the **all** method of repository class + +```php +$this->userRepository->all(); +``` + +### Delete + +To Delete a model record, use the **delete** method of repository class + +```php +$this->userRepository->delete(1); +``` + +The **delete** method can wrok with model instance or the same kind of argument passed to the repository class **find** method . + +```php +$this->userRepository->delete($user); // delete the alredt retrived $user model instance +$this->userRepository->delete(1); // delete user id of 1 +$this->userRepository->delete([1,2,3]); // delete user id of 1,2 and 3 +$this->userRepository->delete(['email' => 'somemail@mail.test']); // delete user with email of somemail@mail.test +``` + +The **delete** method also check for the **SoftDelete** feature , that is if the model is using the **Illuminate\Database\Eloquent\SoftDeletes** trait, the it will do the soft delete of given model records. + +### Force Delete + +To Force Delete a model record, use the **forceDelete** method of repository class + +```php +$this->userRepository->forceDelete(1); +``` + +The **delete** method can wrok with model instance or the same kind of argument passed to the repository class **find** method . + +```php +$this->userRepository->forceDelete($user); // delete the alredt retrived $user model instance +$this->userRepository->forceDelete(1); // delete user id of 1 +$this->userRepository->forceDelete([1,2,3]); // delete user id of 1,2 and 3 +$this->userRepository->forceDelete(['email' => 'somemail@mail.test']); // delete user with email of somemail@mail.test +``` + +The **delete** method also check for the **SoftDelete** feature, that is regardless of the model is using the **Illuminate\Database\Eloquent\SoftDeletes** trait, the it will remove those records from DB. + +### Restore + +To Restore a model record that has soft deleted, use the **forceDelete** method of repository class + +```php +$this->userRepository->restore(1); +``` + +The **restore** will only works for those models that use the **SoftDeletes** feature . It try to use the restore on the model that do not have **SoftDeletes** implemented, it will throw an exception. + +The **restore** method can wrok with model instance or array of model primary keys . + +```php +$this->userRepository->restore($user); // restore the already retrived $user model instance +$this->userRepository->restore(1); // restore user id of 1 +$this->userRepository->restore([1,2,3]); // restore user id of 1,2 and 3test +``` + +## Other Features + +### Get Model + +As this package does not handle all of the features of Eloquent and if any other Eloquent method need to use to build complex query, we need the model instance . to get the model instance + +```php +$this->userRepository->getModel(); +``` + +Also to set/update the model later + +```php +$this->userRepository->setModel(new User); +$this->userRepository->setModel($user); +``` + +### Model Sanitizer + +The BaseRepository class includes a model sanitizer that will automatically sanitize passed array attributes on model record create/update . Here sanatize means it will remove any element from the data array to match with the model table schema while at the same time respecting model **$fillable** and **$hidden** properties . + +The implementation of these methods are as such + +```php +/** + * Sanitize data list to model fillables + * + * @param array $data + * @return array + */ +public function sanitizeToModelFillable(array $data) { + + $classModel = $this->model->getModel(); + $fillable = $classModel->getFillable(); + + $fillables = ! empty($fillable) + ? $fillable + : array_diff( + array_diff( + Schema::getColumnListing($classModel->getTable()), + $classModel->getGuarded() + ), + $classModel->getHidden() + ); + + return array_intersect_key($data, array_flip($fillables)); +} +``` + +So even if extra details passed, it will be ignored or some columns passed that in the **$fillable** or **$hidden** list. + +```php +$user = $this->userRepository->create([ + 'name' => 'User Name', + 'email' => 'somemail@mail.test', + 'password' => Hash::make('password'), + 'date_of_birth' => '1990-12-08' // This date_of_birth column not present in users table +]); +``` + +The above code will run without any issue while a simple model create method will throw exception . + +```php +$user = $this->userRepository->create($request->validated()); + +$profile = $this->profileRepository->create($request->validated()); +``` + +This become very useful when in one single controller method do need to push data to multiple model table + +## Contributing +Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change. + +Please make sure to update tests as appropriate. + +## License +[MIT](./LICENSE.md) diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..309fccb --- /dev/null +++ b/composer.json @@ -0,0 +1,42 @@ +{ + "name": "touhidurabir/laravel-model-repository", + "description": "A package to implement repository pattern for laravel models", + "type": "library", + "license": "MIT", + "authors": [ + { + "name": "Touhidur Rahman", + "email": "abircse06@gmail.com" + } + ], + "require": { + "php": ">=7.2.0" + }, + "autoload" : { + "psr-4" : { + "Touhidurabir\\ModelRepository\\": "src/" + } + }, + "autoload-dev" : { + "psr-4" : { + "Touhidurabir\\ModelRepository\\Tests\\": "tests/" + } + }, + "require-dev": { + "phpunit/phpunit": "^9.5", + "orchestra/testbench": "^6.20", + "illuminate/container": "^8.54", + "illuminate/support": "^8.54", + "illuminate/database": "^8.54" + }, + "extra": { + "laravel": { + "providers": [ + "Touhidurabir\\ModelRepository\\ModelRepositoryServiceProvider" + ], + "aliases": { + "ModelRepository": "Touhidurabir\\ModelRepository\\Facades\\ModelRepository" + } + } + } +} diff --git a/config/model-repository.php b/config/model-repository.php new file mode 100644 index 0000000..45d755d --- /dev/null +++ b/config/model-repository.php @@ -0,0 +1,42 @@ + \Touhidurabir\ModelRepository\BaseRepository::class, + + + /* + |-------------------------------------------------------------------------- + | Default model namespace prefix + |-------------------------------------------------------------------------- + | + | The base model path which will be used to get the full namespace of the + | give model for which the repository class will be genrated . + | + */ + + 'models_namespace' => 'App\\Models', + + + /* + |-------------------------------------------------------------------------- + | Path/location namespace to save repository classes + |-------------------------------------------------------------------------- + | + | location where to store the repository classes to save/store. + | + */ + + 'repositories_namespace' => 'App\\Repositories', + +]; \ No newline at end of file diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..3dbe310 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,41 @@ + + + + + tests + + + + + + src/ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/BaseRepository.php b/src/BaseRepository.php new file mode 100644 index 0000000..d0e0bff --- /dev/null +++ b/src/BaseRepository.php @@ -0,0 +1,408 @@ + + */ + protected $model; + + + /** + * The target model class + * + * @var string + */ + protected $modelClass; + + + /** + * comparison operator + * + * @var string + */ + protected $comparisonOperator = '='; + + + /** + * Determine if array has all the keys numeric + * + * @param array $array + * @return bool + */ + protected function arrayHasAllNumericKeys(array $array) { + + return !(count(array_filter(array_keys($array), 'is_string')) > 0); + } + + + /** + * Get the model class full namespace path + * + * @return string + */ + public function getModelClass() { + + if ( $this->modelClass ) { + + return $this->modelClass; + } + + if ( ! $this->model ) { + + $this->modelClass = get_class($this->model); + } + + return $this->modelClass; + } + + + /** + * Sanitize data list to model fillables + * + * @param array $data + * @return array + */ + public function sanitizeToModelFillable(array $data) { + + $classModel = $this->model->getModel(); + $fillable = $classModel->getFillable(); + + $fillables = ! empty($fillable) + ? $fillable + : array_diff( + array_diff( + Schema::getColumnListing($classModel->getTable()), + $classModel->getGuarded() + ), + $classModel->getHidden() + ); + + return array_intersect_key($data, array_flip($fillables)); + } + + + /** + * Get the extra data that passed to model to create/update + * + * @param array $data + * @return array + */ + public function extraData(array $data) { + + $modelFillables = $this->sanitizeToModelFillable($data); + + return array_diff_key($data, $modelFillables); + } + + + /** + * Does the repository model has soft deleted feature enabled; + * + * @return boolean + */ + public function hasSoftDelete() { + + if ( !$this->modelClass && !$this->getModelClass() ) { + + return false; + } + + return in_array( + 'Illuminate\Database\Eloquent\SoftDeletes', + class_uses($this->modelClass) + ); + } + + + /** + * custom method to bind model to repo + * + * @param object<\Illuminate\Database\Eloquent\Model> $model + * @return $this + */ + public function setModel(Model $model) { + + $this->model = $model; + + return $this; + } + + + /** + * Return binded model instance + * + * @return object<\Illuminate\Database\Eloquent\Model> + */ + public function getModel() { + + return $this->model; + } + + + /** + * Static Factory Method + * + * Initiate the class using static factory method + * Userful for more redability + * Also if the inherited class constructor set to protected or private + * + * @param object<\Illuminate\Database\Eloquent\Model> $model + * @return $this + */ + public static function withModel(Model $model) { + + return new static($model); + } + + + /** + * update the find comparison operator + * + * @param string $operator + * @return $this + */ + public function setComparisor(string $operator = '=') { + + $this->comparisonOperator = $operator; + + return $this; + } + + + /** + * Find specific model instance + * + * @param mixed $param + * @param array $withs + * @param bool $allowException + * + * @return mixed + */ + public function find($param, array $withs = [], bool $allowException = false) { + + $find = $allowException ? 'findOrFail' : 'find'; + $first = $allowException ? 'firstOrFail' : 'first'; + + if ( !is_array($param) || $this->arrayHasAllNumericKeys($param) ) { + + return $this->model->with($withs)->{$find}($param); + } + + return $this->where($param)->with($withs)->getModel()->{$first}(); + } + + + /** + * Model order by clause + * + * @param array $orders + * @return $this + */ + public function orderBy(array $orders = ['id' => 'asc']) { + + $existingOrders = $this->model->getQuery()->orders ?? []; + + foreach ($orders as $column => $direction) { + + if ( ! in_array(compact("column", "direction"), $existingOrders) ) { + + $this->model = $this->model->orderBy($column, $direction); + } + } + + return $this; + } + + + /** + * Model egear load clause + * + * @param array $with + * @return $this + */ + public function with($with = []) { + + if ( ! empty($with) ) { + + $this->model = $this->getModel()->with($with); + } + + return $this; + } + + + /** + * Model Pagination + * + * @param int $perPage + * @param array $columns + * @param string $pageName + * @param int $page + * + * @return mixed + */ + public function paginate( int $perPage = null, + array $columns = ['*'], + string $pageName = 'page', + int $page = null + ) { + + return $this->orderBy()->getModel()->paginate( + $perPage ?? 15, + $columns, + $pageName, + $page ?? 1 + ); + } + + + /** + * Attach where clause + * + * @param array $constrains + * @return $this + */ + public function where(array $constrains = []) { + + foreach ($constrains as $column => $value) { + + $this->model = $this->model->where($column, $value); + } + + return $this; + } + + + /** + * Get all instances of model + * + * @param array $withs + * @return mixed + */ + public function all(array $withs = []) { + + return $this->orderBy()->getModel()->with($withs)->get(); + } + + + /** + * Create a new record in the database + * + * @param array $data + * @return mixed + */ + public function create(array $data) { + + return $this->model->create($this->sanitizeToModelFillable($data)); + } + + + /** + * Update record in the database + * + * @param array $data + * @param mixed $prinamryKeyValue + * + * @return mixed + */ + public function update(array $data, $prinamryKeyValue) { + + $recordToUpdate = $prinamryKeyValue instanceof Model + ? $prinamryKeyValue + : $this->find($prinamryKeyValue); + + $recordToUpdate->update($this->sanitizeToModelFillable($data)); + + return $recordToUpdate->fresh(); + + } + + + /** + * Delete a database record + * + * @param mixed $param + * @return mixed + */ + public function delete($param) { + + $resource = $param instanceof Model ? $param : $this->find($param); + + if ( $resource instanceof Collection ) { + + if ( $this->hasSoftDelete() ) { + + $resource->toQuery()->update([ + $this->model->getDeletedAtColumn() => now() + ]); + } + + $resource->toQuery()->delete(); + + return true; + } + + return $resource->delete(); + } + + + /** + * Force Delete a database record + * + * @param mixed $param + * @return boolean + */ + public function forceDelete($param) { + + $resource = $param instanceof Model ? $param : $this->find($param); + + if ( $resource instanceof Collection ) { + + $resource->toQuery()->delete(); + + return true; + } + + return $this->hasSoftDelete() ? $resource->forceDelete() : $resource->delete(); + } + + + /** + * Restore a soft deleted database record + * + * @param mixed $param + * @return boolean + */ + public function restore($param) { + + if ( ! $this->hasSoftDelete() ) { + + throw new \Exception('The ' . $this->getModelClass() . ' class does not use soft delete feature.'); + } + + $resource = $param instanceof Model ? $param : $this->model->onlyTrashed->findorFail($param); + + if ( $resource instanceof Collection ) { + + $resource->toQuery()->update([ + $this->model->getDeletedAtColumn() => null + ]); + + return true; + } + + return $resource->restore(); + } + +} \ No newline at end of file diff --git a/src/Console/Concerns/ClassFileResolver.php b/src/Console/Concerns/ClassFileResolver.php new file mode 100644 index 0000000..7872e3a --- /dev/null +++ b/src/Console/Concerns/ClassFileResolver.php @@ -0,0 +1,67 @@ + + */ + protected function resolveClassNamespace(string $name) { + + $classFullNameExplode = explode('\\', $name); + + if ( count($classFullNameExplode) <= 1 ) { + + return null; + } + + array_pop($classFullNameExplode); + + return implode('\\', $classFullNameExplode); + } + + + /** + * Generate class store path from give namespace + * + * @param mixed $namespace + * @return mixed + */ + protected function generateFilePathFromNamespace(string $namespace = null) { + + if ( ! $namespace ) { + + return null; + } + + $namespaceSegments = explode('\\', $namespace); + + return '/' . implode('/', array_values(array_filter($namespaceSegments))); + } + +} \ No newline at end of file diff --git a/src/Console/Concerns/CommandExceptionHandler.php b/src/Console/Concerns/CommandExceptionHandler.php new file mode 100644 index 0000000..dc87ae0 --- /dev/null +++ b/src/Console/Concerns/CommandExceptionHandler.php @@ -0,0 +1,31 @@ +error($message ?? 'Exception Arise During Task Execution'); + + $this->error( + 'Exception : ' + . $exception->getMessage() + . " - " + . $exception->getFile() + . " at line " + . $exception->getLine() + ); + } +} \ No newline at end of file diff --git a/src/Console/Concerns/StubGenerator.php b/src/Console/Concerns/StubGenerator.php new file mode 100644 index 0000000..ebbf14b --- /dev/null +++ b/src/Console/Concerns/StubGenerator.php @@ -0,0 +1,222 @@ +requiredClassProps as $prop) { + + if ( ! property_exists($this, $prop) ) { + + $this->error("class must have {$prop} define to generate class file from stub"); + + return false; + } + } + + return true; + } + + + /** + * Save class file generated from stub to the defined location + * + * @param string $name + * @param array $replacers + * + * @return boolean + */ + protected function saveClass($name, $replacers = []) { + + if ( ! $this->validate() ) { return false; } + + $this->generateFilePathDirectory($this->classStorePath); + + $this->files = new Filesystem; + + if ( ! $this->alreadyExists($name) ) { + + $this->files->put($this->getPath($name), $this->buildClass($replacers)); + + return true; + } + + $this->error("class {$name} already exist at path {$this->classStorePath}"); + + return false; + } + + + /** + * Get the stub file for the generator. + * + * @return string + */ + protected function getStub() { + + return str_replace('/Concerns', '', __DIR__) . $this->stubPath; + } + + + /** + * Build class file content by writing the values in the stub file + * + * @param array $replacers + * @return string + */ + protected function buildClass($replacers = []) { + + $this->stub = $this->files->get($this->getStub()); + + foreach ($replacers as $key => $value) { + + if ( is_array($value) ) { + + if ( empty ($value) ) { + $value = '[]'; + } else if ( count($value) == 1 && $value[0][0] == '[' && $value[0][strlen($value[0]) - 1] == ']' ) { + $value = '["' + . implode( + '", "', + array_map( + 'trim', + explode( + ',', + str_replace('[', '', str_replace(']', '', $value[0])) + ) + ) + ) + . '"]'; + } else { + $value = '["'.implode('", "', $value).'"]'; + } + + $this->replaceInStub('"{{'.$key.'}}"', $value); + + continue; + } + + $this->replaceInStub('{{'.$key.'}}', $value); + } + + return $this->stub; + } + + + /** + * Replace the occurance of target string using the provided value + * + * @param string $target + * @param string $content + * + * @return $this + */ + protected function replaceInStub($target ,$content) { + + $this->stub = str_replace($target, $content, $this->stub); + + return $this; + } + + + /** + * Determine if the class already exists. + * + * @param string $rawName + * @return bool + */ + protected function alreadyExists($name) { + + return $this->files->exists($this->getPath($name)); + } + + + /** + * Get the fully qualified store path + * + * @param string $path + * @return string + */ + protected function getStoreDirectoryPath(string $path) { + + return $this->sanitizePath(str_replace('/public', $path, public_path())); + } + + + /** + * Get the destination class path. + * + * @param string $name + * @return string + */ + protected function getPath($name) { + + return $this->getStoreDirectoryPath($this->classStorePath) . $name . '.php'; + } + + + /** + * Check if target path directory exists or not + * If not , create the directory in that path + * + * @param string $path + * @return void + */ + protected function generateFilePathDirectory(string $path) { + + $directoryPath = $this->getStoreDirectoryPath($path); + + File::ensureDirectoryExists($directoryPath); + } + + + /** + * Sanitize the path to proper useable path + * Remove any unecessary slashes + * + * @param string $path + * @return string + */ + protected function sanitizePath(string $path) { + + return preg_replace('#/+#','/', "/" . $path . "/"); + } +} \ No newline at end of file diff --git a/src/Console/Repository.php b/src/Console/Repository.php new file mode 100644 index 0000000..ee9fd02 --- /dev/null +++ b/src/Console/Repository.php @@ -0,0 +1,116 @@ +info('Creating repository class'); + + try { + + $this->classStorePath = $this->generateFilePathFromNamespace( + $this->resolveClassNamespace( + $this->argument('class') + ) ?? config('model-repository.repositories_namespace') + ); + + $saveStatus = $this->saveClass($this->resolveClassName($this->argument('class')), [ + 'class' => $this->resolveClassName($this->argument('class')), + 'model' => $this->resolveClassName($this->option('model')), + 'modelInstance' => lcfirst($this->resolveClassName($this->option('model'))), + 'modelNamespace' => $this->resolveClassNamespace($this->option('model')) ?? config('model-repository.models_namespace'), + 'baseClass' => config('model-repository.base_class'), + 'baseClassName' => last(explode('\\', config('model-repository.base_class'))), + 'classNamespace' => $this->resolveClassNamespace($this->argument('class')) ?? config('model-repository.repositories_namespace'), + ]); + + if ( $saveStatus ) { + + $this->info('Repository class generated successfully'); + } + + } catch (Exception $exception) { + + $this->outputConsoleException($exception); + } + } + +} diff --git a/src/Console/stubs/repository.stub b/src/Console/stubs/repository.stub new file mode 100644 index 0000000..58a930d --- /dev/null +++ b/src/Console/stubs/repository.stub @@ -0,0 +1,23 @@ + ${{modelInstance}} + * @return void + */ + public function __construct({{model}} ${{modelInstance}}) { + + $this->model = ${{modelInstance}}; + + $this->modelClass = get_class(${{modelInstance}}); + } + +} diff --git a/src/Contracts/RepositoryContract.php b/src/Contracts/RepositoryContract.php new file mode 100644 index 0000000..e77639d --- /dev/null +++ b/src/Contracts/RepositoryContract.php @@ -0,0 +1,55 @@ +app->runningInConsole() ) { + $this->commands([ + Repository::class + ]); + } + + $this->mergeConfigFrom( + __DIR__.'/../config/model-repository.php', 'model-repository' + ); + } + + + /** + * Bootstrap any application services. + * + * @return void + */ + public function boot() { + + $this->publishes([ + __DIR__.'/../config/model-repository.php' => base_path('config/model-repository.php'), + ], 'config'); + } + +} \ No newline at end of file diff --git a/tests/App/Models/Profile.php b/tests/App/Models/Profile.php new file mode 100644 index 0000000..50f12a4 --- /dev/null +++ b/tests/App/Models/Profile.php @@ -0,0 +1,27 @@ + $profile + * @return void + */ + public function __construct(Profile $profile) { + + $this->model = $profile; + + $this->modelClass = get_class($profile); + } + +} diff --git a/tests/App/database/migrations/2014_10_12_000000_create_users_table.php b/tests/App/database/migrations/2014_10_12_000000_create_users_table.php new file mode 100644 index 0000000..ff2d6e4 --- /dev/null +++ b/tests/App/database/migrations/2014_10_12_000000_create_users_table.php @@ -0,0 +1,33 @@ +getSchemaBuilder()->create('users', function (Blueprint $table) { + $table->increments('id'); + $table->string('email')->unique(); + $table->string('password', 60); + $table->timestamps(); + $table->softDeletes(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + DB::connection()->getSchemaBuilder()->dropIfExists('users'); + } +} \ No newline at end of file diff --git a/tests/App/database/migrations/2014_10_12_000001_create_profiles_table.php b/tests/App/database/migrations/2014_10_12_000001_create_profiles_table.php new file mode 100644 index 0000000..fd27abc --- /dev/null +++ b/tests/App/database/migrations/2014_10_12_000001_create_profiles_table.php @@ -0,0 +1,33 @@ +getSchemaBuilder()->create('profiles', function (Blueprint $table) { + $table->increments('id'); + $table->string('first_name'); + $table->string('last_name'); + $table->timestamps(); + $table->softDeletes(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + DB::connection()->getSchemaBuilder()->dropIfExists('profiles'); + } +} \ No newline at end of file diff --git a/tests/CommandTest.php b/tests/CommandTest.php new file mode 100644 index 0000000..bbcf92d --- /dev/null +++ b/tests/CommandTest.php @@ -0,0 +1,55 @@ +artisan('make:repository', ['class' => 'UserRepository']); + + $command->assertExitCode(0); + + $command = $this->artisan('make:repository', ['class' => 'UserRepository', '--model' => 'App\\Models\\User']); + + $command->assertExitCode(0); + } + + + /** + * @test + */ + public function repository_command_will_fail_if_repository_class_not_give() { + + $command = $this->artisan('make:repository'); + + $this->expectException(RuntimeException::class); + } + + + /** + * @test + */ + public function repository_command_will_not_create_class_if_already_exists() { + + $this + ->artisan('make:repository', ['class' => 'ProfileRepository']) + ->expectsOutput("class ProfileRepository already exist at path /App/Repositories") + ->assertExitCode(0); + } + +} \ No newline at end of file diff --git a/tests/RepositoryTest.php b/tests/RepositoryTest.php new file mode 100644 index 0000000..54700b9 --- /dev/null +++ b/tests/RepositoryTest.php @@ -0,0 +1,333 @@ + + */ + protected $profileRepository; + + + /** + * Create test repositories + * + * @return void + */ + protected function createRepository() { + + $this->profileRepository = new ProfileRepository(new Profile); + } + + + /** + * Define environment setup. + * + * @param Illuminate\Foundation\Application $app + * @return void + */ + protected function defineEnvironment($app) { + + // Setup default database to use sqlite :memory: + $app['config']->set('database.default', 'testbench'); + $app['config']->set('database.connections.testbench', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]); + + $app['config']->set('app.url', 'http://localhost/'); + $app['config']->set('app.debug', false); + $app['config']->set('app.key', env('APP_KEY', '1234567890123456')); + $app['config']->set('app.cipher', 'AES-128-CBC'); + } + + + /** + * Define database migrations. + * + * @return void + */ + protected function defineDatabaseMigrations() { + + $this->loadMigrationsFrom(__DIR__ . '/App/database/migrations'); + + $this->artisan('migrate', ['--database' => 'testbench'])->run(); + + $this->beforeApplicationDestroyed(function () { + $this->artisan('migrate:rollback', ['--database' => 'testbench'])->run(); + }); + } + + + /** + * Setup the test environment. + * + * @return void + */ + protected function setUp(): void { + + // Code before application created. + + parent::setUp(); + + // Code after application created. + + $this->createRepository(); + } + + + /** + * @test + */ + public function will_have_proper_repository_instance() { + + $this->assertTrue($this->profileRepository instanceof ProfileRepository); + $this->assertTrue($this->profileRepository instanceof BaseRepository); + $this->assertTrue($this->profileRepository instanceof RepositoryContract); + } + + + /** + * @test + */ + public function can_initiate_repository_via_static_method() { + + $alternateProfileRepository = ProfileRepository::withModel(new Profile); + + $this->assertTrue($alternateProfileRepository instanceof ProfileRepository); + $this->assertTrue($alternateProfileRepository instanceof BaseRepository); + $this->assertTrue($alternateProfileRepository instanceof RepositoryContract); + } + + + /** + * @test + */ + public function repository_class_can_return_back_proper_model_instance() { + + $this->assertTrue($this->profileRepository->getModel() instanceof Profile); + $this->assertTrue($this->profileRepository->getModel() instanceof Model); + } + + + /** + * @test + */ + public function repository_class_can_return_back_proper_model_class() { + + $this->assertEquals($this->profileRepository->getModelClass(), Profile::class); + } + + + /** + * @test + */ + public function repository_class_can_check_if_model_has_soft_delete_feature_enabled() { + + $this->assertTrue($this->profileRepository->hasSoftDelete()); + } + + + /** + * @test + */ + public function repository_can_store() { + + $profile = $this->profileRepository->create([ + 'first_name' => 'First_Name', + 'last_name' => 'Last_name' + ]); + + $this->assertDatabaseHas('profiles', [ + 'first_name' => 'First_Name', + 'last_name' => 'Last_name' + ]); + } + + + /** + * @test + */ + public function repository_can_store_on_given_non_associated_table_columns() { + + $profile = $this->profileRepository->create([ + 'first_name' => 'First_Name', + 'last_name' => 'Last_name', + 'some_column' => 'Some data' + ]); + + $this->assertDatabaseHas('profiles', [ + 'first_name' => 'First_Name', + 'last_name' => 'Last_name' + ]); + } + + + /** + * @test + */ + public function repository_can_update() { + + $profile = $this->profileRepository->create([ + 'first_name' => 'First_Name', + 'last_name' => 'Last_name' + ]); + + $this->profileRepository->update([ + 'first_name' => 'New_First_Name', + ], $profile); + + $this->assertDatabaseHas('profiles', [ + 'first_name' => 'New_First_Name', + 'last_name' => 'Last_name' + ]); + } + + + /** + * @test + */ + public function repository_can_find() { + + $profile = $this->profileRepository->create([ + 'first_name' => 'name', + 'last_name' => 'Name' + ]); + + $this->assertEquals($this->profileRepository->find($profile->id)->id, $profile->id); + $this->assertEquals($this->profileRepository->find(['first_name' => 'name'])->id, $profile->id); + } + + /** + * @test + */ + public function repository_can_find_multiple_as_collection() { + + $profile1 = $this->profileRepository->create([ + 'first_name' => 'name1', + 'last_name' => 'Name1' + ]); + + $profile2 = $this->profileRepository->create([ + 'first_name' => 'name2', + 'last_name' => 'Name2' + ]); + + $this->assertEquals($this->profileRepository->find([$profile1->id, $profile2->id])->count(), 2); + $this->assertTrue($this->profileRepository->find([$profile1->id, $profile2->id]) instanceof Collection); + } + + + /** + * @test + */ + public function repository_can_find_via_array_constrain() { + + $profile = $this->profileRepository->create([ + 'first_name' => 'First_Name', + 'last_name' => 'Last_name' + ]); + + $this->assertEquals($this->profileRepository->find(['first_name' => 'First_Name'])->id, $profile->id); + $this->assertEquals($this->profileRepository->find(['first_name' => 'First_Name', 'last_name' => 'Last_name'])->id, $profile->id); + } + + + /** + * @test + */ + public function repository_can_throw_exception_on_failed_find() { + + $this->profileRepository->create([ + 'first_name' => 'First_Name', + 'last_name' => 'Last_name' + ]); + + $this->withoutExceptionHandling(); + + $this->expectException(ModelNotFoundException::class); + + $this->profileRepository->find(100001, [], true); + + $this->profileRepository->find(['first_name' => 'some name'], [], true); + } + + + /** + * @test + */ + public function repository_can_delete() { + + $profile = $this->profileRepository->create([ + 'first_name' => 'name', + 'last_name' => 'Name' + ]); + + $this->profileRepository->delete($profile); + + $this->assertNull($this->profileRepository->find($profile->id)); + $this->assertEquals($this->profileRepository->getModel()->onlyTrashed()->find($profile->id)->id, $profile->id); + } + + + /** + * @test + */ + public function repository_can_force_delete() { + + $profile = $this->profileRepository->create([ + 'first_name' => 'name', + 'last_name' => 'Name' + ]); + + $this->profileRepository->forceDelete($profile); + + $this->assertNull($this->profileRepository->find($profile->id)); + $this->assertNull($this->profileRepository->getModel()->onlyTrashed()->find($profile->id)); + } + + + /** + * @test + */ + public function repository_can_restore() { + + $profile = $this->profileRepository->create([ + 'first_name' => 'name', + 'last_name' => 'Name' + ]); + + $this->profileRepository->delete($profile); + + $this->assertNull($this->profileRepository->find($profile->id)); + + $this->profileRepository->restore($profile); + + $this->assertEquals($this->profileRepository->find($profile->id)->id, $profile->id); + } + +} \ No newline at end of file diff --git a/tests/Traits/LaravelSetup.php b/tests/Traits/LaravelSetup.php new file mode 100644 index 0000000..cd6f7e1 --- /dev/null +++ b/tests/Traits/LaravelSetup.php @@ -0,0 +1,49 @@ +set('database.default', 'testbench'); + $app['config']->set('database.connections.testbench', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]); + + $app['config']->set('app.url', 'http://localhost/'); + $app['config']->set('app.debug', false); + $app['config']->set('app.key', env('APP_KEY', '1234567890123456')); + $app['config']->set('app.cipher', 'AES-128-CBC'); + } + + + /** + * Define database migrations. + * + * @return void + */ + protected function defineDatabaseMigrations() { + + $this->loadMigrationsFrom(__DIR__ . '/App/database/migrations'); + + $this->artisan('migrate', ['--database' => 'testbench'])->run(); + + $this->beforeApplicationDestroyed(function () { + $this->artisan('migrate:rollback', ['--database' => 'testbench'])->run(); + }); + } + +} \ No newline at end of file diff --git a/tests/Traits/LaravelTestBootstrapping.php b/tests/Traits/LaravelTestBootstrapping.php new file mode 100644 index 0000000..2077476 --- /dev/null +++ b/tests/Traits/LaravelTestBootstrapping.php @@ -0,0 +1,36 @@ + ModelRepository::class, + ]; + } +} \ No newline at end of file diff --git a/tests/config/model-repository.php b/tests/config/model-repository.php new file mode 100644 index 0000000..45d755d --- /dev/null +++ b/tests/config/model-repository.php @@ -0,0 +1,42 @@ + \Touhidurabir\ModelRepository\BaseRepository::class, + + + /* + |-------------------------------------------------------------------------- + | Default model namespace prefix + |-------------------------------------------------------------------------- + | + | The base model path which will be used to get the full namespace of the + | give model for which the repository class will be genrated . + | + */ + + 'models_namespace' => 'App\\Models', + + + /* + |-------------------------------------------------------------------------- + | Path/location namespace to save repository classes + |-------------------------------------------------------------------------- + | + | location where to store the repository classes to save/store. + | + */ + + 'repositories_namespace' => 'App\\Repositories', + +]; \ No newline at end of file