Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[12.x] Add resource helper functions to Model/Collections #55107

Merged
merged 18 commits into from
Apr 2, 2025

Conversation

TimKunze96
Copy link
Contributor

@TimKunze96 TimKunze96 commented Mar 20, 2025

This PR adds a couple of helper functions that will make generating resource instances a lot more fluent.

Currently we need to do this:

UserResource::make(User::find(1));

// or

UserResource::collection(User::query()->active()->paginate());

After change:

User::find(1)->toResource(UserResource::class);

User::query()->active()->paginate()->toResourceCollection(UserResource::class);

@TimKunze96 TimKunze96 changed the title Add resource helper functions to Model/Collections [12.x] Add resource helper functions to Model/Collections Mar 20, 2025
@mohammadrasoulasghari
Copy link
Contributor

thanks for pr, but I don't think the difference between UserResource::make(User::find(1)) and User::find(1)->toResource(UserResource::class) is significant enough to justify this API change.

Also, no test has been written for them.

@TimKunze96
Copy link
Contributor Author

TimKunze96 commented Mar 22, 2025

I agree that it is not a big difference at a first glance, but especially when queries become quite long it is a significant upgrade in terms of readability. At the moment we are forced to introduce an intermediate variable or nest long query builder calls. This can get messy real fast, especially when being used in conjunction with defered/optional props in Inertia.js that require a function.

// Messy nesting required for shorthand function

$modules = fn () => ModuleResource::collection(Module::query()
  ->withDefaultRelations()
  // potentially many more calls
  ->withCommonSearch($search)
  ->simplePaginate(40, page: $search->page));

// or forced to use normal function

$modules =  function () use ($search) {
  $models = Module::query()
  ->withDefaultRelations()
  // potentially many more calls
  ->withCommonSearch($search)
  ->simplePaginate(40, page: $search->page);

  return ModuleResource::collection($modules);
 }

// arguably easier to write and more readable

$modules =  fn () => Module::query()
  ->withDefaultRelations()
  ->withCommonSearch($search)
  // potentially many more calls
  ->simplePaginate(40, page: $search->page)
  ->toResourceCollection(ModuleResource::class);

I'm happy to write the necessary tests for it too if the discussion about adding this feature is still open.

Also this is a pure enhancement, the "old way" of creating resources would still be valid.

@taylorotwell
Copy link
Member

taylorotwell commented Mar 23, 2025

@TimKunze96 I kinda dig this. I wonder if we could add some auto-discovery to it based on conventions. 👀

So that you could just do User::find(1)->toResource() or User::all()->toResourceCollection() if you want, while still allowing explicit arguments as well.

@TimKunze96
Copy link
Contributor Author

I added a basic implementation of this. I think there is technically an edge case bug in there: Since the Paginator Instance does not "remember" what model it got generated by (as far as I could tell) I have to infer the model type via the items, but if the item list is empty it would just run fine, even if the related resource does not exist.

So to make it absolutely bulletproof the paginator would need to remember the calling class.


$model = $this->first();

assert(is_object($model), 'Resource collection guesser expects the collection to contain objects.');
Copy link
Contributor

@mohammadrasoulasghari mohammadrasoulasghari Mar 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using assertions in production code is risky as they may be disabled in production environments (zend.assertions=-1). When assertions fail in this state, no proper errors are thrown, leading to silent failures or unexpected behavior. Better to use explicit exceptions with meaningful error messages for reliable error handling.

https://www.php.net/manual/en/ini.core.php#ini.zend.assertions

see : \Illuminate\Database\Eloquent\Collection::toQuery

@TimKunze96
Copy link
Contributor Author

I changed the code to explicitly throw a LogicException if the provided or auto-discovered resource does not exist. There are now tests that cover all possible cases for each model, collection, and paginator.

The tests currently use class aliases to simulate the existence of the corresponding resource classes, although there may be a better approach.

*
* @return class-string<JsonResource>
*/
protected function guessResourceName(): string
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a lot of duplications, maybe we can avoid the duplications?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah you are right. I refactored the code to use traits instead.

@TimKunze96
Copy link
Contributor Author

TimKunze96 commented Mar 26, 2025

I refactored to code to make use of traits.

It also gets applied to the base collection class instead of the eloquent collection class, since there are many cases were users mutate query results and end up with regular collections instead of eloquent collections.

@shaedrich
Copy link
Contributor

shaedrich commented Mar 27, 2025

@TimKunze96 I kinda dig this. I wonder if we could add some auto-discovery to it based on conventions. 👀

So that you could just do User::find(1)->toResource() or User::all()->toResourceCollection() if you want, while still allowing explicit arguments as well.

For custom collections it might also be convenient to define the collections resource class on the collection class via $collectionClass or newCollection() as either

  • a class property (e.g. $resourceClass)
  • a getter method (e.g. newResource() or getResourceClass() or getResourceClassName())
  • an attribute (e.g. #[CollectionResource] or #[ResourceClass])

throw_unless(class_exists($resourceClass), \LogicException::class, sprintf('Failed to find resource class for model [%s].', $className));
throw_unless(
class_exists($resourceClass = static::guessResourceName()),
LogicException::class, sprintf('Failed to find resource class for model [%s].', get_class($this))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd put each argument on a separate line for consistency

Suggested change
LogicException::class, sprintf('Failed to find resource class for model [%s].', get_class($this))
LogicException::class,
sprintf('Failed to find resource class for model [%s].', get_class($this))

Comment on lines 36 to 41
throw_unless(
class_exists($resourceClass = static::guessResourceName()),
LogicException::class, sprintf('Failed to find resource class for model [%s].', get_class($this))
);

return $resourceClass::make($this);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might be a decent use case for tap():

Suggested change
throw_unless(
class_exists($resourceClass = static::guessResourceName()),
LogicException::class, sprintf('Failed to find resource class for model [%s].', get_class($this))
);
return $resourceClass::make($this);
$resourceClass = tap(static::guessResourceName(), fn (string $resourceClass) => throw_unless(
class_exists($resourceClass),
LogicException::class, sprintf('Failed to find resource class for model [%s].', get_class($this))
));
return $resourceClass::make($this);

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be nice if one could do something like this:

        $resourceClass = throw_unless(
            static::guessResourceName(),
            class_exists(...),
            LogicException::class, sprintf('Failed to find resource class for model [%s].', get_class($this))
        );

        return $resourceClass::make($this);

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or if there was a global when() helper 🤔

        $resourceClass = when(
            static::guessResourceName(),
            class_exists(...),
            fn () => throw new LogicException(sprintf('Failed to find resource class for model [%s].', get_class($this))),
        );

        return $resourceClass::make($this);

Copy link
Contributor

@shaedrich shaedrich Apr 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or a Stringable helper 🤔

Suggested change
throw_unless(
class_exists($resourceClass = static::guessResourceName()),
LogicException::class, sprintf('Failed to find resource class for model [%s].', get_class($this))
);
return $resourceClass::make($this);
$resourceClass = Str::of(static::guessResourceName())
->when(
class_exists(...),
fn () => throw new LogicException(sprintf('Failed to find resource class for model [%s].', get_class($this))),
)
->value();
return $resourceClass::make($this);

@taylorotwell taylorotwell merged commit cc889e6 into laravel:12.x Apr 2, 2025
38 of 39 checks passed
@taylorotwell
Copy link
Member

Thanks!

@rodrigopedra
Copy link
Contributor

@taylorotwell as outlined on issue #55272, this PR added a missing dependency to illuminate/http to the illuminate/database component.

As such, projects using only the illuminate/database component outside of Laravel are failing due to the missing dependency.

I am not sure if this could be considered a breaking change due to this.

crynobone added a commit that referenced this pull request Apr 7, 2025
Avoid regression issue introduced in #55107

fixes #55272

Signed-off-by: Mior Muhammad Zaki <[email protected]>
crynobone added a commit that referenced this pull request Apr 7, 2025
Avoid regression issue introduced in #55107

fixes #55272

Signed-off-by: Mior Muhammad Zaki <[email protected]>
taylorotwell added a commit that referenced this pull request Apr 7, 2025
* [12.x] Fix `illuminate/database` usage as standalone package

Avoid regression issue introduced in #55107

fixes #55272

Signed-off-by: Mior Muhammad Zaki <[email protected]>

* Update composer.json

* Update composer.json

---------

Signed-off-by: Mior Muhammad Zaki <[email protected]>
Co-authored-by: Taylor Otwell <[email protected]>
@grantholle
Copy link

I tried doing something very similar in 2020... #35515

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

7 participants