headlesslaravel / formations

MIT License
3 stars 5 forks source link

Includes #6

Open dillingham opened 2 years ago

dillingham commented 2 years ago

This package provides "Formations" which is class that provides resource API routes with the responses. So a PostFormation enables /posts and /posts/1 endpoints for example with working eloquent queries. "Includes" are a way to add data to each item within a formation's index results.

For example, you may want to add posts.*.comment_count or posts.*.author or author.*.posts You define the allowed includes within a formation's includes() method.

Then you tell the request which "includes" to make active via the url: ?includes=posts_count

Then when a request is made, any includes are added to results within the formation->builder() method

The unfinished Includes class: https://github.com/headlesslaravel/formations/blob/main/src/Includes.php

The tests needing to pass: https://github.com/headlesslaravel/formations/blob/main/tests/IncludesTest.php

(Just test stubs with some initial thoughts on assertions or setup)

Assuming a PostFormation

Within PostFormation:

public $model = \App\Models\Post::class;

public function includes()
{
    return [
        // get the count of the reactions relationship nested under the comments relationship (withCount)

        Includes::make('reaction_count', 'comments.reactions')->count(),

        // get the sum of the upvotes column on the comments relationship (withMax)

        Includes::make('comment_upvotes', 'comments')->sum('upvotes'),

        // get the maximum of the score column on the nested comments.rating relationship (withMax)

        Includes::make('highest_vote', 'comments.rating')->max('score'),

        // get the minimum of the score column on the nested comments.rating relationship (withMin)

        Includes::make('lowest_vote', 'comments.rating')->min('score'),

        // get the average of the rating column on the reviews relationship (withAvg) 

        Includes::make('average_rating', 'reviews')->avg('rating'),

        // Add scopeActive from Rating model class nested under post.comments relationship
       // And add policy check via can() and pass response through Api Resource 
        Includes::make('ratings', 'comments.rating')
            ->resource(RatingResource::class)
            ->can('viewAny', Rating::class)
            ->scope('active'),
    ];
}
/users?includes:ratings,reaction_count,comment_upvotes,highest_vote,lowest_vote,average_rating
{
    "users": [
        {
            "name": "Brian",
            "highest_vote": "1988"
            "lowest_vote": "2021"
            "average_rating": "5"
            "reaction_count": "5000",
            "ratings": [
                 {
                        "author": "Jane",
                        "score": "5",
                  }
            ]
        }
    ]
}

dynamic scopes: passing parameters to a scope when using includes

/users?includes=accounts&account-status=active
Includes::make('accounts', 'users.accounts')
    ->scope('status', Request::input('account-status'));
    ->rule('account-status', ['required', 'in:active,inactive,draft']); 
public function scopeStatus($query, $status)
{
    $query->where('status', $status');
}

if rules contains nullable, the request parameter is optional, if not or contains required, its required and will be trigger validation error when request has the include but does not contain the include's scope parameter

Optional parameter

Includes::make('accounts', 'users.accounts')
    ->scope('status', Request::input('account-status'))
    ->rule('account-status', 'nullable|in:active,draft');
public function scopeStatus($query, $status = null)
{
    if($status) {
        $query->where('status', $status');
    }
}

Accessors

If not a relationship, it can be a accessor.. in which case the include would append json

Includes::make('full_name', 'getFullnameAttribute'),
public function getFullnameAttribute()
{
    return "{$this->firstname} {$this->lastname}";
}
dillingham commented 2 years ago

<?php

namespace HeadlessLaravel\Formations\Tests;

use HeadlessLaravel\Formations\Tests\Fixtures\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Gate;

class IncludesTest extends TestCase
{
    use RefreshDatabase;

//    public function test_relationship_include()
//    {
//        $this->markTestIncomplete();
//        // PostFormation
//        // Includes::make('author')
//        $author = User::factory()->create();
//
//        $this->get('posts?includes=author')
//            ->assertJsonPath('data.0.author.id', $author->id)
//            ->assertJsonPath('data.1.author.id', $author->id);
//    }
//
//    public function test_value_on_relationship_include()
//    {
//        $this->markTestIncomplete();
//        // PostFormation
//        // Includes::make('author_name', 'author.name')
//        // example: post.author.name posts?includes=author_name
//        $author = User::factory()->create();
//
//        $this->get('posts?includes=author_name')
//            ->assertJsonPath('data.0.author_name', $author->name)
//            ->assertJsonPath('data.1.author_name', $author->name);
//    }
//
//    public function test_value_on_nested_relationship_include()
//    {
//        $this->markTestIncomplete();
//        // PostFormation
//        // Includes::make('author_post_titles', 'author.posts.title')
//        // post.author.posts.title posts?includes=author_post_titles
//        $author = User::factory()->create();
//
//        $this->get('posts?includes=author_name')
//            ->assertJsonPath('data.0.author_post_titles.0', $author->posts->pluck('title')[0])
//            ->assertJsonPath('data.0.author_post_titles.1', $author->posts->pluck('title')[1]);
//    }
//
//    public function test_scope_on_relationship_include()
//    {
//        $this->markTestIncomplete();
//        // PostFormation
//        // Includes::make('comments')->scope('approved')
//        // post.comments /posts?includes=comments
//        // public function scopeApproved($query) {}
//    }
//
//    public function test_scope_with_required_parameter_on_relationship_include()
//    {
//        $this->markTestIncomplete();
//        // PostFormation
//        // Includes::make('comments')
//        // ->scope('status', Request::input('status'))
//        // ->rule('status', ['required'])
//        // post.comments /posts?includes=comments&comment_status=approved
//        // public function scopeStatus($query, $status) {}
//        $this->get('posts?includes=comments&status=approved')
//            ->assertJsonCount(1, 'data.0.comments')
//            ->assertJsonPath('data.0.comments.0.status', 'approved');
//
//        $this->get('posts?includes=comments&status=draft')
//            ->assertJsonCount(1, 'data.0.comments')
//            ->assertJsonPath('data.0.comments.0.status', 'draft');
//    }
//
//    public function test_scope_with_optional_parameter_on_relationship_include()
//    {
//        $this->markTestIncomplete();
//        // PostFormation
//        // Includes::make('comments')
//        // ->scope('status', Request::input('status'))
//        // ->rule('status', ['nullable'])
//        // public function scopeStatus($query, $status = null) { if($status) { $query->thing(); }}
//        // post with coment with status approved
//        // post with coment with status draft
//        $this->get('posts?includes=comments&status=')
//            ->assertJsonCount(2, 'data.0.comments');
//    }
//
//    public function test_scope_with_required_parameter_on_relationship_include_with_validation_error()
//    {
//        $this->markTestIncomplete();
//        // post.comments ?status=invalid
//        // ->scope('status', Request::input('status'))
//        // ->rule('status', ['required', 'in:approved,draft'])
//        $this->get('posts?includes=comments&status=invalid')
//            ->assertInvalid('status');
//    }
//
//    public function test_no_includes_in_response_when_not_authorized()
//    {
//        $this->markTestIncomplete();
//        Gate::define('viewAuthor', function () {
//            return true;
//        });
//
//        Gate::define('viewComments', function () {
//            return false;
//        });
//
//        // Includes::make('author')->can('viewAuthor'),
//        // Includes::make('coments')->can('viewComments'),
//        // assert that comments are not in the response
//        $author = User::factory()->create();
//
//        $this->get('posts?includes=comments,author')
//            ->assertJsonPath('data.0.comments', null)
//            ->assertJsonPath('data.0.author.id', $author->id);
//    }
//
//    public function test_an_includes_with_an_api_resource_response()
//    {
//        $this->markTestIncomplete();
//        // https://laravel.com/docs/8.x/eloquent-resources
//        // Includes::make('author')->resource(AuthorResource::class),
//        // /posts?includes=author
//        // define a resource and add it to an includes
//        // call a relationship
//        // assert that only values within the resource are present
//        $author = User::factory()->create();
//
//        $this->get('posts?includes=author')
//            ->assertJsonCount(2, 'data.0.author') // only 2 keys id, name
//            ->assertJsonPath('data.0.author.id', $author->id)
//            ->assertJsonPath('data.0.author.name', $author->name);
//    }
//
//    public function test_includes_count_aggregate()
//    {
//        $this->markTestIncomplete();
//        // https://laravel.com/docs/8.x/eloquent-relationships#counting-related-models
//        // Include::make('comments')->count()
//        // create a post with two comments
//        // assert response includes "comments_count": 2
//    }
//
//    public function test_includes_count_aggregate_with_scope()
//    {
//        $this->markTestIncomplete();
//        // https://laravel.com/docs/8.x/eloquent-relationships#counting-related-models
//        // Include::make('approved_comments', 'comments')->scope('approved')->count()
//        // create a post with two comments, one approved one not
//        // assert response includes count 1 not 2 for custom key "approved_comments"
//    }
//
//    public function test_includes_min_aggregate()
//    {
//        $this->markTestIncomplete();
//        // https://laravel.com/docs/8.x/eloquent-relationships#other-aggregate-functions
//        // Include::make('min_comment_id', 'comments')->min('id')
//        // create a post with two comments
//        // assert response includes first comment's id not the second
//        // since its value will be lower than the later comment's id
//    }
//
//    public function test_includes_max_aggregate()
//    {
//        $this->markTestIncomplete();
//        // https://laravel.com/docs/8.x/eloquent-relationships#other-aggregate-functions
//        // Include::make('max_comment_id', 'comments')->max('id')
//        // create a post with two comments
//        // assert response includes second comment's id not the first
//        // since its value will be higher than the later comment's id
//    }
//
//    public function test_includes_avg_aggregate()
//    {
//        $this->markTestIncomplete();
//        // https://laravel.com/docs/8.x/eloquent-relationships#other-aggregate-functions
//        // Include::make('max_comment_id', 'comments')->avg('rating')
//        // create 2 posts with three comments each with different ratings
//        // assert that the average of those ratings is outputted
//    }
//
//    public function test_includes_sum_aggregate()
//    {
//        $this->markTestIncomplete();
//        // https://laravel.com/docs/8.x/eloquent-relationships#other-aggregate-functions
//        // Include::make('rating_sum', 'comments')->sum('rating')
//        // create 2 posts with three comments each with different ratings
//        // assert that the sum of those ratings is outputted
//    }
//
//    public function test_includes_exists_aggregate()
//    {
//        $this->markTestIncomplete();
//        // https://laravel.com/docs/8.x/eloquent-relationships#other-aggregate-functions
//        // Include::make('is_liked', 'likes')->exists()
//        // create 1 posts with 1 like
//        // create 1 post with 0 likes
//        $this->get('posts?include=is_liked')
//            ->assertJsonPath('data.0.is_liked', true)
//            ->assertJsonPath('data.1.is_liked', false);
//    }
//
//    public function test_exception_for_aggregate_on_non_collection_relationship()
//    {
//        $this->markTestIncomplete();
//        // abort_if BelongsTo, HasOne, HasOneThrough,MorphOne
//    }
}