Beefed up query-builder and repository to reduce boilerplate and keep your controllers lean
Features:
- Get started with zero config. See basic examples
- The usual suspects:
all
,find
,create
,update
,destroy
. - Plus
allResources
,findResource
, and so on to wrap in resources. list()
function useful for<select>
and autocompletes.- Front-end and user driven. The request is the context for what gets included:
- filter query on model and relations
- include relations (all blocked by default)
- deal with paging transparently
- order models by their props or their relations props (with no custom SQL)
- 100% test coverage
The goal of this package:
- Avoid boilerplate for paged, filtered, and sorted resources
- Let the client decided what to fetch in a safe way
- Separate controller logic from model logic
- Let you be in control of the query for special cases
Practically the repository class is a mix between a query builder and a repository with emphasis on the retrieving stuff.
This package includes one interfaces and three classes for you to build on:
Repository
class, to use as is or extend for your model needs.ResourceContext
interface, provides data to the repository.RequestResourceContext
, draws data from the incoming Request object.ArrayResourceContext
, you provide the data. Good for testing.
You can install the package via composer:
composer require mblarsen/laravel-repository
The repository relies on a ResourceContext
to provide it with the necessary
values to be able to sort, filter, paginate and so on. This is handled
automatically, but it means that you should let Laravel's depency injection
provide a repository for you. This is espesically powerfull if you extende the
base repository class.
The default context that is used is the RequestResourceContext
which is
automatically injected into the repository. This is espesically useful when you
are building public or private APIs to serve a front-end. However, another
implementation is provided that lets you provide the control to a higher
degree. This is the ArrayResourceContext
. This implementation is useful for
testing or for when you build a traditional Laravel application using Blade
views.
The following examples a biased toward use of the RequestResourceContext
.
The base repository knows nothing of your models, so unless you sub-class the repository, you must specify what model you are querying.
// Using Laravel's resolve() helper
$repository = resolve(Repository::class)->setModel(Post::class);
// Using static factory
$repository = Repository::for(Post::class);
When used in controllers it is recommended letting Laravel do the work for you:
public function index(Repository $repository)
{
return $repository->setModel(Post::class)->all();
}
... or in case of a custom repository:
public function index(PostRepository $repository)
{
return $repository->all();
}
The result is now automatically sorted, filtered, and paginated according to the request.
Example requests that you'll be able to provide now:
/posts?sort_by=created_at&page=2&filters[title]=laravel
That is:
/posts
, request postssort_by=created_at
, sort by created_atsort_by=desc
, sort in descending order (default:asc
)page=2
, paginated result, request page 2 (default:null
meaing not paginated)filters[title]=laravel
search for title in the posts name
You can also filter by relationships. E.g. filters[address.zip]=1227
.
Since relations are disallowed by default nothing requests to include are ignored. But once we set that up your will be able to request relations as well:
with[]=ads&with[]=comments
, will include the relationsads
andcomments
.
You can define the behaviour of the repository using a chained API. Be sure to check the full API below.
Query: with
or with[]
The repository will include any relations specified in with
based on what is
allowed. That ensures that the client cannot request data that you have not
allowed.
$repository->setAllowedWith(['comments']);
Some times you want to include certain relations by default. In addition to doing that on the model directly the Laravel way you also have the option to set which on the repository. This allows you to control the list of relation per action in the controller.
$relation->setDefaultWith(['comments']);
Aside: if you are building a public api (for your SPA or 3rd party) it is
recommended that you wrap the result a JsonResource
. This will give you
control of what properties are exposed and will allow you to transform the data
further. See resources section.
Query: filter[key]=value
This package provides a search like functionality through its filters. Under
the hood it uses LIKE
, ie. %value%
.
If you want exact matches, ie. =
, instead you can add an exclamation point to
then end of the key name.
The key doesn't have to be properties on the main model. It can be relation properties as well. Here are some examples:
// Search on model property
title=cra
// Search on model property exact match
code_name!=wowcrab
// Seacch on relation property
address.city=mass
You can combine properties in a search:
// Search in full name
first_name+middle_name+last_name=cra
And lastly you can choose to search for the same value in multiple properties:
// Search in both title, name, and email
title|name|email=cra
A different way to filter is to provider a query builder to all()
and
find()
. See examples in the API.
You cannot modify the request context directly, but you have the option to
convert it to an ArrayResourceContext
.
public function index(UserRepository $repository)
{
$repository->setContext(
ArrayResourceContext::create(
$repository->getContext()->toArray()
)
->merge([
'filters' => ['status' => 'approved']
])
->exclude(['search_by'])
);
}
Many of your models will likely not need as custom (sub-classed) repository. But often your core models have more logic associated with them. In that case it is advised do extend the base repository.
All the properties except the model
can be omitted. Well, you can omitted the
model too but that is kind of pointless.
Disclaimer: the example's purpose is to demo the flexibility not and isn't very real world.
class PostRepository extends Repository
{
// We serve you Posts
protected $model = Post::class;
// The client can request to include the following relations
protected $allowed_with = ['ads', 'comments'];
// However, we will include these automatically
protected $default_with = ['comments'];
// We change the default sort key to created_at ...
protected $default_sort_by = 'created_at';
// ... in descending order
protected $default_sort_order = 'desc';
// We override modelQuery to ensure only
// published posts will be returned
protected function modelQuery($query = null)
{
// It is perfectly okay to not invoke the parent.
// It simly defaults to an empty query of the current
// model. These are identical:
//
// $query = parent::modelQuery($query);
// $query = $query ?: Post::query()
$query = parent::modelQuery($query);
$query->whereNotNul('published_at');
return $query;
}
}
You can achieve the same with the base repository, but of course then you would have to repeat the setup every time:
public function index(Repository $repository)
{
$only_published = Post::query()->whereNotNul('published_at');
$repository
->setModel(Post::class)
->setDefaulSort('created_at', 'desc')
->setAllowedWith(['ads', 'comments'])
->setDefaultWith(['comments'])
;
return $repository->all($only_published);
}
Versus:
public function index(PostRepository $repository)
{
return $repository->all();
}
If you are building a public api (for your SPA or 3rd party) it is recommended
that you wrap the result a JsonResource
. This will give you control of what
properties are exposed and will allow you to transform the data further.
Doing is really easy.
// using base repository
$repository->setResource(UserResource::class);
// or extending
protected $resource = UserResource::class;
Then you just use any of the API methods prepending Resource
or Resources
as shown here:
$repository->allResources();
$repository->findResource(3);
$repository->createResource(['name' => 'foo']);
$repository->updateResource($user, ['name' => 'foo']);
If you implement a collection resource you can set that as the second arguments
to the setResource
method call:
$repository->setResource(UserResource::class, UserResourceCollection::class);
// or in class
protected $resource_collection = UserResourceCollection::class
Note: list()
doesn't support resources. It didn't make any sense to me. You
are welcome to change my mind.
The default values that the RequestResourceContext
looks for in incoming request are:
filters
page
per_page
sort_by
sort_order
with
If you don't like these or for some reason cannot use them. You have the option
to provide your own keys using mapKeys()
on the context. mapKeys()
lets you
map one or more keys to your preferred scheme.
By far the easiest way to do this is add this bit of code in your AppServiceProvider
.
public function register()
{
$this->app->resolving(RequestResourceContext::class, function ($context) {
$context->mapKeys([
'filters' => 'filter'
'sort_by' => 'order_by',
'sort_order' => 'direction',
'per_page' => 'take',
]);
// or if you simply prefer camelCase
$context->mapKeys([
'sort_by' => 'sortBy',
'sort_order' => 'sortOrder',
'per_page' => 'perPage',
]);
});
}
So as where this would be the old way:
/posts?sort_by=title&per_page=10&with=comments
your client now should use this scheme:
/posts?sortBy=title&perPage=10&with=comments
When it comes to testing the ArrayResourceContext
comes in handy. It lets you
pass in context values directly.
$repository = Repository::for(User::class, ArrayResourceContext::create([
'filters' => [
'status' => 'approved',
]
]));
// or shorter
$repository = Repository::for(User::class, [
'filters' => [
'status' => 'approved',
]
]);
all($query = null)
list(string|callabel $column, $query = null)
find($id, $query = null)
create(array $data)
update(Model $model, array $data)
destroy(Model $model)
setContext(ResourceContext|array $resource_context)
setModel(string $model)
setAllowedWith(array $allowed)
setDefaultSort(string $by, string $order = 'asc')
setDefaultWith(array $with)
shouldAuthorize(bool $value = true)
When extending the base repository you may want to check out these additional functions:
Return all models given the current resource context.
public function index(UserRepository $user_repository)
{
// To get only users with status 'pending'
$query = User::where('status', 'pending');
$users = $user_repository->all($query);
// Of course in this simple case we could have achived the same using a
// filter: GET /users?filters[status]=pending
}
Use with Query
suffix to return as a query builder.
Use with Resources
suffix to return as a resource collection.
Produces a result suitable for selects, lists, and autocomplete. All entries that has a 'value' and a 'label' key.
If $column
is omitted the default sort by is used. In many cases they'll be
the same anyway.
Use with Query
suffix to return as a query builder.
Note: if a callable is used the mapping is performed in memory, while a string is done in the database layer. Also note that when using the listQuery variant the callable is not reflected.
Gets a single model. You can further narrow down the result by providing a
start query. See example in all()
Use with Query
suffix to return as a query builder.
Use with Resource
suffix to return as a resource.
Typical crUd. Use with Resource
suffix to return as a resource.
Typical crUd.
Use with Resource
suffix to return as a resource.
Typical cruD.
This method lets you set or change the context after the repository is created.
See example code.
See example code.
See example code.
See example code.
Called when the repository is created. This is useful setting up this
default_list_column
function for a sub-classed repository.
protected function register()
{
$this->default_list_column = function ($model) {
return $model->full_name;
};
}
When set to true authorization is performed before any query is performed.
method | action |
---|---|
all | viewAny |
list | viewAny |
find | viewAny |
create | create |
update | create |
destroy | destroy |
Example:
Repository::for(Post::class)
->shouldAuthorize()
->update($post, ['author' => 'me');
See ResourceContext implementation.
Please see CHANGELOG for more information what has changed recently.
If you discover any security related issues, please email m19n@pm.me instead of using the issue tracker.
The MIT License (MIT). Please see License File for more information.
This package was generated using the Laravel Package Boilerplate.
Please see CONTRIBUTING for details.
Please see DEVELOPING for help getting started.
Thanks goes to these wonderful people (emoji key):
Michael Bøcker-Larsen 💻 📖 🚧 |
This project follows the all-contributors specification. Contributions of any kind welcome!