From 562c65baec6c73f4e92238fdc538823fde463f3d Mon Sep 17 00:00:00 2001 From: Eduard Lupacescu Date: Thu, 28 Aug 2025 15:56:36 +0300 Subject: [PATCH 1/5] fix: adding search using lazy loading for belongs to --- RELEASE.md | 92 ++++++++++++++++++ UPGRADING.md | 93 +++++++++++++++++++ config/restify.php | 14 +++ docs-v2/content/en/api/fields.md | 73 +++++++++++++++ .../Concerns/CanLoadLazyRelationship.php | 31 +++++++ src/Fields/EagerField.php | 8 +- src/Fields/Field.php | 2 + src/Fields/HasMany.php | 4 + src/Filters/SearchableFilter.php | 60 ++++++++---- src/Filters/SearchablesCollection.php | 7 ++ .../Controllers/RepositoryShowController.php | 6 +- src/Http/Controllers/RestResponse.php | 1 + src/MCP/Concerns/McpShowTool.php | 6 +- src/MCP/Concerns/McpToolHelpers.php | 2 +- src/Repositories/Repository.php | 78 ++++++++-------- .../Search/RepositorySearchService.php | 93 ++++++++++++++++--- src/Traits/InteractWithSearch.php | 27 +++++- tests/Feature/BelongsToJoinConfigTest.php | 89 ++++++++++++++++++ tests/Fields/BelongsToFieldTest.php | 7 +- tests/Fields/FieldTest.php | 17 ++++ tests/Fields/LazyRelationshipTest.php | 63 +++++++++++++ 21 files changed, 684 insertions(+), 89 deletions(-) create mode 100644 RELEASE.md create mode 100644 src/Fields/Concerns/CanLoadLazyRelationship.php create mode 100644 tests/Feature/BelongsToJoinConfigTest.php create mode 100644 tests/Fields/LazyRelationshipTest.php diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 000000000..2f5f69377 --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,92 @@ +# Release Notes + +This document provides a high-level overview of major features and changes in Laravel Restify. For detailed documentation and implementation guides, please refer to the comprehensive documentation. + +## Version 10.x + +### ๐Ÿš€ Major Features + +#### Model Context Protocol (MCP) Integration + +Laravel Restify now provides seamless integration with the Model Context Protocol (MCP), allowing AI agents to interact with your REST API resources through structured tool interfaces. Transform your repositories into tools for AI agents to consume! + +**Quick Setup:** +```php +use Binaryk\LaravelRestify\MCP\RestifyServer; +use Laravel\Mcp\Facades\Mcp; + +// Web-based MCP server with authentication +Mcp::web('restify', RestifyServer::class) + ->middleware(['auth:sanctum']) + ->name('mcp.restify'); +``` + +**Key Benefits:** AI-Ready APIs, Zero Configuration, Built-in Security, Web & Terminal Access + +๐Ÿ“– **[Complete MCP Documentation โ†’](docs-v2/content/en/mcp/mcp.md)** + +#### Lazy Relationship Loading for Fields + +Fields can now be configured to lazy load relationships, preventing N+1 queries for computed attributes: + +```php +field('profileTagNames', fn() => $this->model()->profileTagNames) + ->lazy('tags'), +``` + +๐Ÿ“– **[Lazy Loading Documentation โ†’](docs-v2/content/en/api/fields.md#lazy-loading)** + +#### JOIN Optimization for BelongsTo Search + +Performance optimization replacing slow subqueries with efficient JOIN operations. Enable via configuration: + +```php +// config/restify.php +'search' => [ + 'use_joins_for_belongs_to' => env('RESTIFY_USE_JOINS_FOR_BELONGS_TO', false), +], +``` + +๐Ÿ“– **[Performance Optimization Guide โ†’](UPGRADING.md#join-optimization)** + +#### Enhanced Field Methods + +New and improved field methods with flexible signatures: +- **`searchable()`** - Unified flexible signature with multiple argument support +- **`matchable()`** - Various match types and advanced filtering scenarios +- **`sortable()`** - Custom columns and conditional sorting + +๐Ÿ“– **[Field Methods Documentation โ†’](docs-v2/content/en/api/fields.md)** + +### โš ๏ธ Breaking Changes + +#### Default Search Behavior Change + +Repositories no longer search by primary key (ID) by default when no searchable fields are defined. + +**Migration Path:** +```php +public static function searchables(): array { + return empty(static::$search) ? [static::newModel()->getKeyName()] : static::$search; +} +``` + +๐Ÿ“– **[Complete Migration Guide โ†’](UPGRADING.md)** + +### ๐Ÿ”ง Technical Improvements + +- **Scout Integration**: Enhanced error handling and graceful degradation +- **Column Qualification**: Improved handling for JOIN operations +- **SearchablesCollection**: Fixed string callable handling +- **Configuration**: New options with environment variable support + +## ๐Ÿ“š Documentation & Resources + +- **[Complete Documentation](docs-v2/content/en/)** - Comprehensive guides and examples +- **[Migration Guide](UPGRADING.md)** - Step-by-step upgrade instructions +- **[MCP Integration](docs-v2/content/en/mcp/mcp.md)** - AI agent setup and configuration +- **[Field Reference](docs-v2/content/en/api/fields.md)** - All field methods and options + +## ๐Ÿงช Testing + +All new features include comprehensive test coverage to ensure reliability and maintainability. \ No newline at end of file diff --git a/UPGRADING.md b/UPGRADING.md index f0c689047..789fda744 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -119,6 +119,92 @@ class UserRepository extends Repository This change is also **100% backward compatible** - existing static arrays continue to work perfectly. +#### Enhanced BelongsTo Search Performance with Configurable JOINs + +Laravel Restify v10 introduces a significant performance optimization for BelongsTo relationship searches by replacing slow subqueries with efficient JOINs. This feature is configurable and enabled by default for better performance. + +**Performance Impact:** + +**Before (v9 and earlier - Subquery approach):** +```sql +-- Slow subquery-based search +SELECT * FROM users WHERE ( + (SELECT name FROM organizations WHERE organizations.id = users.organization_id LIMIT 1) LIKE '%Tech%' + OR + (SELECT phone FROM organizations WHERE organizations.id = users.organization_id LIMIT 1) LIKE '%Tech%' +) +``` + +**After (v10 - Optimized JOIN approach):** +```sql +-- Fast JOIN-based search with proper column selection +SELECT users.* FROM users +LEFT JOIN organizations ON users.organization_id = organizations.id +WHERE (organizations.name LIKE '%Tech%' OR organizations.phone LIKE '%Tech%') +``` + +**Configuration Options:** + +The JOIN optimization can be controlled via configuration: + +```php +// config/restify.php +'search' => [ + 'case_sensitive' => true, + + /* + | Use JOINs for BelongsTo Relationships + | When enabled, BelongsTo relationship searches will use JOINs instead of + | subqueries for better performance. This is generally recommended for + | better query performance, but can be disabled if compatibility issues arise. + | Default: true (recommended for better performance) + */ + 'use_joins_for_belongs_to' => env('RESTIFY_USE_JOINS_FOR_BELONGS_TO', true), +], +``` + +**Environment Variable Control:** +```bash +# .env file +RESTIFY_USE_JOINS_FOR_BELONGS_TO=true # Enable JOINs (default, recommended) +RESTIFY_USE_JOINS_FOR_BELONGS_TO=false # Disable JOINs (legacy subqueries) +``` + +**Benefits of JOIN optimization:** +- ๐Ÿš€ **Better Performance** - JOINs are significantly faster than subqueries for relationship searches +- ๐Ÿ“Š **Improved Scalability** - Better performance with large datasets +- ๐Ÿ”ง **Automatic Column Qualification** - Prevents column name conflicts in complex queries +- โšก **Pagination Optimization** - Both main and count queries benefit from JOINs + +**When to disable JOINs:** +- ๐Ÿ”„ **During migration** - Test both approaches during deployment +- ๐Ÿ› **Compatibility issues** - If you encounter any edge cases with complex queries +- ๐Ÿ“Š **Specific database setups** - Some database configurations may prefer subqueries +- ๐Ÿงช **Testing phases** - Compare performance in your specific environment + +**Migration Strategy:** + +1. **Default behavior** - JOINs are enabled by default for better performance +2. **No code changes needed** - Existing BelongsTo searches automatically benefit +3. **Easy rollback** - Set `RESTIFY_USE_JOINS_FOR_BELONGS_TO=false` to revert to v9 behavior +4. **Gradual testing** - Test in development/staging before production deployment + +**Example Usage:** +```php +// This automatically benefits from JOIN optimization in v10 +class PostRepository extends Repository +{ + public static array $related = [ + 'user' => BelongsTo::make('user', UserRepository::class) + ->searchable(['name', 'email']), + 'organization' => BelongsTo::make('organization', OrganizationRepository::class) + ->searchable(['name', 'phone']), + ]; +} +``` + +This change is **100% backward compatible** with an option to disable if needed. The optimization is transparent to your application code while providing significant performance improvements. + ## Breaking Changes ### Default Search Behavior Change @@ -179,6 +265,13 @@ When upgrading to v10, it's important to ensure your local `config/restify.php` ```php // Example new sections (check the actual config file for current options) +'search' => [ + 'case_sensitive' => true, + + // New: JOIN optimization for BelongsTo searches (v10+) + 'use_joins_for_belongs_to' => env('RESTIFY_USE_JOINS_FOR_BELONGS_TO', true), +], + 'mcp' => [ 'tools' => [ 'exclude' => [], diff --git a/config/restify.php b/config/restify.php index aef1e8488..fda7c751d 100644 --- a/config/restify.php +++ b/config/restify.php @@ -132,6 +132,20 @@ | Specify either the search should be case-sensitive or not. */ 'case_sensitive' => true, + + /* + |-------------------------------------------------------------------------- + | Use JOINs for BelongsTo Relationships + |-------------------------------------------------------------------------- + | + | When enabled, BelongsTo relationship searches will use JOINs instead of + | subqueries for better performance. This is generally recommended for + | better query performance, but can be disabled if compatibility issues arise. + | + | Default: true (recommended for better performance) + | + */ + 'use_joins_for_belongs_to' => env('RESTIFY_USE_JOINS_FOR_BELONGS_TO', false), ], 'repositories' => [ diff --git a/docs-v2/content/en/api/fields.md b/docs-v2/content/en/api/fields.md index 03248629c..b4a817cce 100644 --- a/docs-v2/content/en/api/fields.md +++ b/docs-v2/content/en/api/fields.md @@ -1374,6 +1374,79 @@ class AvatarStore implements Storable You can use the php artisan restify:store AvatarStore command to generate a store file. +## Lazy Loading + +Fields can be configured to lazy load relationships, which is particularly useful for computed attributes that depend on related models. This helps avoid N+1 queries by ensuring relationships are loaded only when needed. + +### Making Fields Lazy + +Use the `lazy()` method to mark a field for lazy loading: + +```php +public function fields(RestifyRequest $request) +{ + return [ + // Lazy load the 'tags' relationship when displaying profileTagNames + field('profileTagNames', fn() => $this->model()->profileTagNames) + ->lazy('tags'), + + // Lazy load using the field's attribute name (if it matches the relationship) + field('tags', fn() => $this->model()->tags->pluck('name')) + ->lazy(), + + // Another example with user relationship + field('authorName', fn() => $this->model()->user->name ?? 'Unknown') + ->lazy('user'), + ]; +} +``` + +### How It Works + +When you have a model attribute like this: + +```php +class Post extends Model +{ + public function getProfileTagNamesAttribute(): array + { + return $this->tags()->pluck('name')->toArray(); + } + + public function tags() + { + return $this->belongsToMany(Tag::class); + } +} +``` + +You can create a field that efficiently loads this data: + +```php +field('profileTagNames', fn() => $this->model()->profileTagNames) + ->lazy('tags') +``` + +This ensures that: +1. The `tags` relationship is loaded before the field value is computed +2. Multiple fields using the same relationship won't cause additional queries +3. The computed value can safely access the relationship data + +### Lazy Loading Methods + +The `CanLoadLazyRelationship` trait provides the following methods: + +- `lazy(?string $relationshipName = null)` - Mark the field as lazy and optionally specify the relationship name +- `isLazy(RestifyRequest $request)` - Check if the field is configured for lazy loading +- `getLazyRelationshipName()` - Get the name of the relationship to lazy load + +### Benefits + +- **Performance**: Prevents N+1 queries when dealing with computed attributes +- **Efficiency**: Relationships are loaded only once, even if multiple fields depend on them +- **Flexibility**: Works with any relationship type (BelongsTo, HasMany, ManyToMany, etc.) +- **Clean Code**: Keeps your field definitions simple while ensuring optimal database usage + ## Utility Methods ### Repository Management diff --git a/src/Fields/Concerns/CanLoadLazyRelationship.php b/src/Fields/Concerns/CanLoadLazyRelationship.php new file mode 100644 index 000000000..17ebf0518 --- /dev/null +++ b/src/Fields/Concerns/CanLoadLazyRelationship.php @@ -0,0 +1,31 @@ +lazyRelationshipName = $relationshipName ?? $this->getAttribute(); + + return $this; + } + + public function isLazy(RestifyRequest $request): bool + { + return !is_null($this->lazyRelationshipName); + } + + public function getLazyRelationshipName(): ?string + { + return $this->lazyRelationshipName; + } +} diff --git a/src/Fields/EagerField.php b/src/Fields/EagerField.php index bd9d62797..67a893827 100644 --- a/src/Fields/EagerField.php +++ b/src/Fields/EagerField.php @@ -45,7 +45,7 @@ public function __construct($attribute, ?string $parentRepository = null) if (is_null($parentRepository)) { $this->repositoryClass = tap(Restify::repositoryClassForKey(str($attribute)->pluralStudly()->kebab()->toString()), - fn ($repository) => abort_unless($repository, 400, "Repository not found for the key [$attribute].")); + fn($repository) => abort_unless($repository, 400, "Repository not found for the key [$attribute].")); } if (! isset($this->repositoryClass)) { @@ -61,9 +61,9 @@ public function __construct($attribute, ?string $parentRepository = null) public function authorize(Request $request) { return call_user_func( - [$this->repositoryClass, 'authorizedToUseRepository'], - $request - ) && parent::authorize($request); + [$this->repositoryClass, 'authorizedToUseRepository'], + $request + ) && parent::authorize($request); } public function resolve($repository, $attribute = null) diff --git a/src/Fields/Field.php b/src/Fields/Field.php index 4e1dab7b2..dcd9488c6 100644 --- a/src/Fields/Field.php +++ b/src/Fields/Field.php @@ -2,6 +2,7 @@ namespace Binaryk\LaravelRestify\Fields; +use Binaryk\LaravelRestify\Fields\Concerns\CanLoadLazyRelationship; use Binaryk\LaravelRestify\Fields\Concerns\CanMatch; use Binaryk\LaravelRestify\Fields\Concerns\CanSearch; use Binaryk\LaravelRestify\Fields\Concerns\CanSort; @@ -26,6 +27,7 @@ class Field extends OrganicField implements JsonSerializable, Matchable, Sortabl use CanMatch; use CanSearch; use CanSort; + use CanLoadLazyRelationship; use FieldMcpSchemaDetection; use HasAction; use Make; diff --git a/src/Fields/HasMany.php b/src/Fields/HasMany.php index f884786e2..dc320cccd 100644 --- a/src/Fields/HasMany.php +++ b/src/Fields/HasMany.php @@ -6,6 +6,7 @@ use Binaryk\LaravelRestify\Http\Requests\RestifyRequest; use Binaryk\LaravelRestify\Repositories\Repository; use Illuminate\Auth\Access\AuthorizationException; +use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Http\Request; class HasMany extends EagerField @@ -28,6 +29,9 @@ public function resolve($repository, $attribute = null) if ($repository->model()->relationLoaded($this->relation)) { $paginator = $repository->model()->getRelation($this->relation); } else { + /** + * @var Relation $paginator + */ $paginator = $repository->{$this->relation}(); $paginator = $paginator ->take(request('relatablePerPage') ?? ($this->repositoryClass::$defaultRelatablePerPage ?? RestifySearchable::DEFAULT_RELATABLE_PER_PAGE)) diff --git a/src/Filters/SearchableFilter.php b/src/Filters/SearchableFilter.php index 9c603aeb2..e3f7a5b9f 100644 --- a/src/Filters/SearchableFilter.php +++ b/src/Filters/SearchableFilter.php @@ -11,7 +11,7 @@ class SearchableFilter extends Filter public array $computedColumns = []; - private BelongsTo $belongsToField; + public BelongsTo $belongsToField; protected $customClosure = null; @@ -27,26 +27,47 @@ public function filter(RestifyRequest $request, $query, $value) $likeOperator = $connectionType == 'pgsql' ? 'ilike' : 'like'; if (isset($this->belongsToField)) { - ray('Searching through BelongsTo relation'); if (! $this->belongsToField->authorize($request)) { - ray('BelongsTo field not authorized for this request, skipping search.'); return $query; } - // TODO: This approach could be optimized using JOIN instead of subquery for better performance - // Current implementation uses subqueries which work correctly but may be slower for large datasets - collect($this->belongsToField->getSearchables())->each(function (string $attribute) use ($query, $likeOperator, $value) { - $query->orWhere( - $this->belongsToField->getRelatedModel($this->repository)::select($attribute) - ->whereColumn( - $this->belongsToField->getQualifiedKey($this->repository), - $this->belongsToField->getRelatedKey($this->repository) - ) - ->take(1), - $likeOperator, - "%{$value}%" - ); - }); + // Check if JOINs are enabled in config + if (config('restify.search.use_joins_for_belongs_to', false)) { + // JOINs are applied at the service level, so we just need to apply search conditions + $relatedModel = $this->belongsToField->getRelatedModel($this->repository); + $relatedTable = $relatedModel->getTable(); + + // Apply search conditions using qualified column names from the joined table + collect($this->belongsToField->getSearchables())->each(function (string $attribute) use ($query, $likeOperator, $value, $relatedTable) { + // Check if the attribute is already qualified (contains a dot) + $qualifiedColumn = str_contains($attribute, '.') + ? $attribute + : $relatedTable . '.' . $attribute; + + $query->orWhere($qualifiedColumn, $likeOperator, "%{$value}%"); + }); + } else { + // Use the original subquery approach when JOINs are disabled + collect($this->belongsToField->getSearchables())->each(function (string $attribute) use ($query, $likeOperator, $value) { + $query->orWhere(function ($subQuery) use ($attribute, $likeOperator, $value) { + $relation = $this->belongsToField->getRelation($this->repository); + $relatedModel = $this->belongsToField->getRelatedModel($this->repository); + $relatedTable = $relatedModel->getTable(); + $foreignKey = $relation->getForeignKeyName(); + $ownerKey = $relation->getOwnerKeyName(); + + // Build the subquery: (SELECT column FROM related_table WHERE related_table.key = main_table.foreign_key LIMIT 1) + $qualifiedColumn = str_contains($attribute, '.') ? $attribute : $relatedTable . '.' . $attribute; + $localTableForeignKey = $this->repository->model()->getTable() . '.' . $foreignKey; + $relatedTableOwnerKey = $relatedTable . '.' . $ownerKey; + + $subQuery->whereRaw( + "(SELECT {$qualifiedColumn} FROM {$relatedTable} WHERE {$relatedTableOwnerKey} = {$localTableForeignKey} LIMIT 1) {$likeOperator} ?", + ["%{$value}%"] + ); + }); + }); + } return $query; } @@ -89,4 +110,9 @@ public function hasCustomClosure(): bool { return ! is_null($this->customClosure); } + + public function hasBelongsTo(): bool + { + return isset($this->belongsToField); + } } diff --git a/src/Filters/SearchablesCollection.php b/src/Filters/SearchablesCollection.php index 0fb015ea2..fd210b204 100644 --- a/src/Filters/SearchablesCollection.php +++ b/src/Filters/SearchablesCollection.php @@ -134,4 +134,11 @@ public function qualifyColumns(Model $model): self } }); } + + public function onlyBelongsTo(): self + { + return $this->filter( + fn(SearchableFilter $filter) => $filter->hasBelongsTo() + ); + } } diff --git a/src/Http/Controllers/RepositoryShowController.php b/src/Http/Controllers/RepositoryShowController.php index 5107620e4..4b21b137e 100644 --- a/src/Http/Controllers/RepositoryShowController.php +++ b/src/Http/Controllers/RepositoryShowController.php @@ -11,10 +11,10 @@ public function __invoke(RepositoryShowRequest $request): Response { $repository = $request->repository(); - return $request->repositoryWith(tap($request->modelQuery(), fn ($query) => $repository::showQuery( + return $request->repositoryWith(tap($request->modelQuery(), fn($query) => $repository::showQuery( $request, - $repository::mainQuery($request, $query->with($repository::withs())) - ))->with($repository::withs())->firstOrFail()) + $repository::mainQuery($request, $query->with($repository::collectWiths($request, $repository)->all())) + ))->with($repository::collectWiths($request, $repository)->all())->firstOrFail()) ->allowToShow($request) ->show($request, request('repositoryId')); } diff --git a/src/Http/Controllers/RestResponse.php b/src/Http/Controllers/RestResponse.php index 612f34028..d342bd346 100644 --- a/src/Http/Controllers/RestResponse.php +++ b/src/Http/Controllers/RestResponse.php @@ -3,6 +3,7 @@ namespace Binaryk\LaravelRestify\Http\Controllers; use Binaryk\LaravelRestify\Contracts\RestifySearchable; +use Binaryk\LaravelRestify\MCP\Requests\McpRequest; use Binaryk\LaravelRestify\Repositories\Repository; use Binaryk\LaravelRestify\Repositories\RepositoryCollection; use Illuminate\Contracts\Pagination\Paginator; diff --git a/src/MCP/Concerns/McpShowTool.php b/src/MCP/Concerns/McpShowTool.php index 2bd248446..943b8866e 100644 --- a/src/MCP/Concerns/McpShowTool.php +++ b/src/MCP/Concerns/McpShowTool.php @@ -23,8 +23,10 @@ public function showTool(array $arguments, McpRequest $request): array // Apply showQuery and mainQuery with proper relationship loading $model = tap($query, fn ($query) => static::showQuery( $request, - static::mainQuery($request, $query->with(static::withs())) - ))->with(static::withs())->findOrFail($id); + static::mainQuery($request, $query->with(static::collectWiths( + $request, $this + )->all())) + ))->findOrFail($id); // Set the model on the repository instance and authorize $repository = static::resolveWith($model); diff --git a/src/MCP/Concerns/McpToolHelpers.php b/src/MCP/Concerns/McpToolHelpers.php index 7c3937af2..03d924c53 100644 --- a/src/MCP/Concerns/McpToolHelpers.php +++ b/src/MCP/Concerns/McpToolHelpers.php @@ -78,7 +78,7 @@ protected static function formatRelationshipDocumentation(): string $documentation .= "\nField Selection:\n"; $documentation .= "You can specify which fields to include for each relationship using square brackets.\n"; - $documentation .= "Syntax: relationship[field1,field2] or relationship[field1|field2] (both work)\n\n"; + $documentation .= "Syntax: relationship[field1|field2]\n\n"; $documentation .= "Nested Relationships:\n"; $documentation .= "You can include deeply nested relationships using dot notation with field selection at each level.\n"; diff --git a/src/Repositories/Repository.php b/src/Repositories/Repository.php index 11c246ba5..95723a8a8 100644 --- a/src/Repositories/Repository.php +++ b/src/Repositories/Repository.php @@ -428,10 +428,10 @@ public function resolveShowAttributes(RestifyRequest $request) { $fields = $this->collectFields($request) ->forShow($request, $this) - ->filter(fn (Field $field) => $field->authorize($request)) - ->each(fn (Field $field) => $field->resolveForShow($this)) - ->map(fn (Field $field) => $field->serializeToValue($request)) - ->mapWithKeys(fn ($value) => $value) + ->filter(fn(Field $field) => $field->authorize($request)) + ->each(fn(Field $field) => $field->resolveForShow($this)) + ->map(fn(Field $field) => $field->serializeToValue($request)) + ->mapWithKeys(fn($value) => $value) ->all(); if ($this instanceof Mergeable) { @@ -496,17 +496,17 @@ public function resolveIndexAttributes($request) ->collectFields($request) ->when( $this->hasCustomColumns(), - fn (FieldCollection $fields) => $fields->inList($this->getColumns()) + fn(FieldCollection $fields) => $fields->inList($this->getColumns()) ) ->forIndex($request, $this) - ->filter(fn (Field $field) => $field->authorize($request)) + ->filter(fn(Field $field) => $field->authorize($request)) ->when( $this->eagerState, - fn ($items) => $items->filter(fn (Field $field) => ! $field instanceof EagerField) + fn($items) => $items->filter(fn(Field $field) => ! $field instanceof EagerField) ) - ->each(fn (Field $field) => $field->resolveForIndex($this)) - ->map(fn (Field $field) => $field->serializeToValue($request)) - ->mapWithKeys(fn ($value) => $value) + ->each(fn(Field $field) => $field->resolveForIndex($this)) + ->map(fn(Field $field) => $field->serializeToValue($request)) + ->mapWithKeys(fn($value) => $value) ->all(); } @@ -540,10 +540,10 @@ public function resolveShowPivots(RestifyRequest $request): array } return $pivots - ->filter(fn (Field $field) => $field->authorize($request)) - ->each(fn (Field $field) => $field->resolve($this)) - ->map(fn (Field $field) => $field->serializeToValue($request)) - ->mapWithKeys(fn ($value) => $value) + ->filter(fn(Field $field) => $field->authorize($request)) + ->each(fn(Field $field) => $field->resolve($this)) + ->map(fn(Field $field) => $field->serializeToValue($request)) + ->mapWithKeys(fn($value) => $value) ->all(); } @@ -569,8 +569,8 @@ public function resolveRelationships($request): array ->forRequest($request, $this) ->mapIntoRelated($request, $this) ->unserialized($request, $this) - ->when($this->isForMcp(), fn (RelatedCollection $collection) => $collection->forMcp($request, $this)) - ->map(fn (Related $related) => $related->resolve($request, $this)->getValue()) + ->when($this->isForMcp(), fn(RelatedCollection $collection) => $collection->forMcp($request, $this)) + ->map(fn(Related $related) => $related->resolve($request, $this)->getValue()) ->map(function (mixed $items) { if ($items instanceof Collection) { return $items->filter()->values(); @@ -636,13 +636,13 @@ public function indexAsArray(RestifyRequest $request): array return $repository->authorizedToShow($request); })->values(); - $data = $items->map(fn (self $repository) => $repository->serializeForIndex($request)); + $data = $items->map(fn(self $repository) => $repository->serializeForIndex($request)); return $this->filter([ 'meta' => $this->when( $meta = $this->resolveIndexMainMeta( $request, - $models = $items->map(fn (self $repository) => $repository->resource), + $models = $items->map(fn(self $repository) => $repository->resource), [ 'current_page' => $paginator->currentPage(), 'from' => $paginator->firstItem(), @@ -728,14 +728,14 @@ public function store(RestifyRequest $request) } } - $fields->each(fn (Field $field) => $field->invokeAfter($request, $this->resource)); + $fields->each(fn(Field $field) => $field->invokeAfter($request, $this->resource)); $this ->collectFields($request) ->forStore($request, $this) ->withActions($request, $this) ->authorizedStore($request) - ->each(fn (Field $field) => $field->actionHandler->handle($request, $this->resource)); + ->each(fn(Field $field) => $field->actionHandler->handle($request, $this->resource)); }); if (method_exists(static::class, 'stored')) { @@ -764,14 +764,14 @@ public function storeBulk(RepositoryStoreBulkRequest $request) $this->resource->save(); - $fields->each(fn (Field $field) => $field->invokeAfter($request, $this->resource)); + $fields->each(fn(Field $field) => $field->invokeAfter($request, $this->resource)); $this ->collectFields($request) ->forStoreBulk($request, $this) ->withActions($request, $this, $row) ->authorizedUpdateBulk($request) - ->each(fn (Field $field) => $field->actionHandler->handle($request, $this->resource, $row)); + ->each(fn(Field $field) => $field->actionHandler->handle($request, $this->resource, $row)); return $this->resource; }); @@ -798,7 +798,7 @@ public function update(RestifyRequest $request, $repositoryId) return $fields; })->each( - fn (Field $field) => $field->invokeAfter($request, $this->resource) + fn(Field $field) => $field->invokeAfter($request, $this->resource) ); $this @@ -806,7 +806,7 @@ public function update(RestifyRequest $request, $repositoryId) ->forUpdate($request, $this) ->withActions($request, $this) ->authorizedUpdate($request) - ->each(fn (Field $field) => $field->actionHandler->handle($request, $this->resource)); + ->each(fn(Field $field) => $field->actionHandler->handle($request, $this->resource)); return data($this->serializeForShow($request)); } @@ -818,7 +818,7 @@ public function patch(RestifyRequest $request, $repositoryId) DB::transaction(function () use ($request, $keys) { $fields = $this->collectFields($request) ->filter( - fn (Field $field) => in_array($field->attribute, $keys), + fn(Field $field) => in_array($field->attribute, $keys), ) ->forUpdate($request, $this) ->withoutActions($request, $this) @@ -837,18 +837,18 @@ public function patch(RestifyRequest $request, $repositoryId) return $fields; })->each( - fn (Field $field) => $field->invokeAfter($request, $this->resource) + fn(Field $field) => $field->invokeAfter($request, $this->resource) ); $this ->collectFields($request) ->filter( - fn (Field $field) => in_array($field->attribute, $keys), + fn(Field $field) => in_array($field->attribute, $keys), ) ->forUpdate($request, $this) ->withActions($request, $this) ->authorizedPatch($request) - ->each(fn (Field $field) => $field->actionHandler->handle($request, $this->resource)); + ->each(fn(Field $field) => $field->actionHandler->handle($request, $this->resource)); return data($this->serializeForShow($request)); } @@ -869,7 +869,7 @@ public function updateBulk(RestifyRequest $request, $repositoryId, int $row) ->forUpdateBulk($request, $this) ->withActions($request, $this, $row) ->authorizedUpdateBulk($request) - ->each(fn (Field $field) => $field->actionHandler->handle($request, $this->resource, $row)); + ->each(fn(Field $field) => $field->actionHandler->handle($request, $this->resource, $row)); return response()->json(); } @@ -894,7 +894,7 @@ public function attach(RestifyRequest $request, $repositoryId, Collection $pivot $eagerField = $this->authorizeBelongsToMany($request)->belongsToManyField($request); DB::transaction(function () use ($request, $pivots, $eagerField) { - $fields = $eagerField->collectPivotFields()->filter(fn ( + $fields = $eagerField->collectPivotFields()->filter(fn( $pivotField ) => $request->has($pivotField->attribute))->values(); @@ -938,7 +938,7 @@ public function detach(RestifyRequest $request, $repositoryId, Collection $pivot $deleted = DB::transaction(function () use ($pivots, $eagerField, $request) { return $pivots - ->map(fn ($pivot) => $eagerField->authorizeToDetach($request, $pivot) && $pivot->delete()); + ->map(fn($pivot) => $eagerField->authorizeToDetach($request, $pivot) && $pivot->delete()); }); return data($deleted, 204); @@ -981,7 +981,7 @@ public function allowToAttach(RestifyRequest $request, Collection $attachers): s { $methodGuesser = 'attach'.Str::studly($request->relatedRepository); - $attachers->each(fn ($model) => $this->authorizeToAttach($request, $methodGuesser, $model)); + $attachers->each(fn($model) => $this->authorizeToAttach($request, $methodGuesser, $model)); return $this; } @@ -999,7 +999,7 @@ public function allowToDetach(RestifyRequest $request, Collection $attachers): s { $methodGuesser = 'detach'.Str::studly($request->relatedRepository); - $attachers->each(fn ($model) => $this->authorizeToDetach($request, $methodGuesser, $model)); + $attachers->each(fn($model) => $this->authorizeToDetach($request, $methodGuesser, $model)); return $this; } @@ -1103,7 +1103,7 @@ public function serializeForShow(RestifyRequest $request): array $this->request = $request; return $this->filter([ - 'id' => $this->when(optional($this->resource)?->getKey(), fn () => $this->getId($request)), + 'id' => $this->when(optional($this->resource)?->getKey(), fn() => $this->getId($request)), 'type' => $this->when($type = $this->getType($request), $type), 'attributes' => $request->isShowRequest() ? $this->resolveShowAttributes($request) : $this->resolveIndexAttributes($request), 'relationships' => $this->when(value($related = $this->resolveRelationships($request)), $related), @@ -1171,7 +1171,7 @@ private function modelAttributes(?Request $request = null): Collection */ protected static function fillFields(RestifyRequest $request, Model $model, Collection $fields) { - return $fields->map(fn (Field $field) => $field->fillAttribute($request, $model)); + return $fields->map(fn(Field $field) => $field->fillAttribute($request, $model)); } protected static function fillBulkFields( @@ -1239,14 +1239,14 @@ public function restifyjsSerialize(RestifyRequest $request): array 'match' => static::collectFilters('matches'), 'searchables' => static::collectFilters('searchables'), 'actions' => $this->resolveActions($request) - ->filter(fn (mixed $action) => $action instanceof Action) - ->filter(fn (Action $action) => $action->isShownOnIndex( + ->filter(fn(mixed $action) => $action instanceof Action) + ->filter(fn(Action $action) => $action->isShownOnIndex( $request, $this ))->values(), 'getters' => $this->resolveGetters($request) - ->filter(fn (mixed $action) => $action instanceof Getter) - ->filter(fn (Getter $action) => $action->isShownOnIndex( + ->filter(fn(mixed $action) => $action instanceof Getter) + ->filter(fn(Getter $action) => $action->isShownOnIndex( $request, $this ))->values(), diff --git a/src/Services/Search/RepositorySearchService.php b/src/Services/Search/RepositorySearchService.php index 514d02723..ae83b217b 100644 --- a/src/Services/Search/RepositorySearchService.php +++ b/src/Services/Search/RepositorySearchService.php @@ -5,6 +5,8 @@ use Binaryk\LaravelRestify\Events\AdvancedFiltersApplied; use Binaryk\LaravelRestify\Fields\EagerField; use Binaryk\LaravelRestify\Filters\AdvancedFiltersCollection; +use Binaryk\LaravelRestify\Filters\SearchableFilter; +use Binaryk\LaravelRestify\Filters\SearchablesCollection; use Binaryk\LaravelRestify\Http\Requests\RestifyRequest; use Binaryk\LaravelRestify\Repositories\Repository; use Illuminate\Database\Eloquent\Builder; @@ -34,9 +36,9 @@ public function search(RestifyRequest $request, Repository $repository): Builder $shouldUseScout ? $this->prepareRelations($request, $scoutQuery ?? $repository::query($request)) : $this->prepareSearchFields( - $request, - $this->prepareRelations($request, $scoutQuery ?? $repository::query($request)), - ), + $request, + $this->prepareRelations($request, $scoutQuery ?? $repository::query($request)), + ), ); $query = $this->applyFilters($request, $repository, $query); @@ -84,7 +86,7 @@ public function prepareRelations(RestifyRequest $request, Builder|Relation $quer $eager = ($this->repository)::collectRelated() ->forRequest($request, $this->repository) ->map( - fn ($relation) => $relation instanceof EagerField + fn($relation) => $relation instanceof EagerField ? $relation->relation : $relation ) @@ -96,8 +98,8 @@ public function prepareRelations(RestifyRequest $request, Builder|Relation $quer return $query; } - $filtered = collect($request->related()->makeTree())->filter(fn (string $relationships) => in_array( - str($relationships)->whenContains('.', fn (Stringable $string) => $string->before('.'))->toString(), + $filtered = collect($request->related()->makeTree())->filter(fn(string $relationships) => in_array( + str($relationships)->whenContains('.', fn(Stringable $string) => $string->before('.'))->toString(), $eager, true, ))->filter(function ($relation) use ($query) { @@ -109,7 +111,7 @@ public function prepareRelations(RestifyRequest $request, Builder|Relation $quer })->all(); return $query->with( - array_merge($filtered, ($this->repository)::withs()) + array_merge($filtered, ($this->repository)::collectWiths($request, $this->repository)->all()), ); } @@ -123,11 +125,15 @@ public function prepareSearchFields(RestifyRequest $request, $query) $model = $query->getModel(); - $query->where(function ($query) use ($search, $model, $request) { - $connectionType = $model->getConnection()->getDriverName(); + // Collect all searchables and conditionally apply JOINs for BelongsTo relationships + $searchablesCollection = $this->repository::collectSearchables($request, $this->repository); + + if (config('restify.search.use_joins_for_belongs_to', false)) { + $this->applyBelongsToJoins($query, $searchablesCollection, $request); + } - // Collect all searchables using the unified approach - $searchablesCollection = $this->repository::collectSearchables($request, $this->repository); + $query->where(function ($query) use ($search, $model, $request, $searchablesCollection) { + $connectionType = $model->getConnection()->getDriverName(); $hasSearchableFields = $searchablesCollection->isNotEmpty(); @@ -152,14 +158,14 @@ public function prepareSearchFields(RestifyRequest $request, $query) protected function applyIndexQuery(RestifyRequest $request, Repository $repository) { if ($request->isIndexRequest() || $request->isGlobalRequest()) { - return fn ($query) => $repository::indexQuery($request, $query); + return fn($query) => $repository::indexQuery($request, $query); } if ($request->isShowRequest()) { - return fn ($query) => $repository::showQuery($request, $query); + return fn($query) => $repository::showQuery($request, $query); } - return fn ($query) => $query; + return fn($query) => $query; } public function initializeQueryUsingScout(RestifyRequest $request, Repository $repository): Builder @@ -187,7 +193,9 @@ function ($scoutBuilder) use ($repository, $request) { protected function applyMainQuery(RestifyRequest $request, Repository $repository): callable { - return fn ($query) => $repository::mainQuery($request, $query->with($repository::withs())); + return fn($query) => $repository::mainQuery($request, $query->with($repository::collectWiths( + $request, $repository + )->all())); } protected function applyFilters(RestifyRequest $request, Repository $repository, $query) @@ -256,6 +264,61 @@ protected function isScoutAvailable(Repository $repository): bool } } + /** + * Preemptively apply JOINs for all BelongsTo relationships that will be searched. + */ + private function applyBelongsToJoins( + $query, + SearchablesCollection $searchablesCollection, + RestifyRequest $request + ): void { + $searchablesCollection + ->onlyBelongsTo() + ->each(function (SearchableFilter $searchable) use ($query, $request) { + $belongsToField = $searchable->belongsToField; + + // Verify authorization + if (! $belongsToField->authorize($request)) { + return; + } + + try { + // Get relationship details with error handling + $relatedModel = $belongsToField->getRelatedModel($this->repository); + $relatedTable = $relatedModel->getTable(); + + $relation = $belongsToField->getRelation($this->repository); + $foreignKey = $relation->getForeignKeyName(); + $ownerKey = $relation->getOwnerKeyName(); + + // Build fully qualified column names + $localTableForeignKey = $this->repository->model()->getTable().'.'.$foreignKey; + $relatedTableOwnerKey = $relatedTable.'.'.$ownerKey; + + // Add JOIN only if it hasn't been added already + $joinAlreadyExists = collect($query->toBase()->joins ?? [])->contains(function ($join) use ( + $relatedTable + ) { + return $join->table === $relatedTable; + }); + + if (! $joinAlreadyExists) { + $query->leftJoin($relatedTable, $localTableForeignKey, '=', $relatedTableOwnerKey); + + // Ensure we only select columns from the main table to avoid column conflicts + // Only set select if it hasn't been set already + if (empty($query->getQuery()->columns)) { + $mainTable = $this->repository->model()->getTable(); + $query->select([$mainTable.'.*']); + } + } + } catch (\Exception $e) { + // Skip this JOIN if the relationship doesn't exist or has issues + // This allows the code to gracefully handle missing relationships + } + }); + } + public static function make(): static { return new static; diff --git a/src/Traits/InteractWithSearch.php b/src/Traits/InteractWithSearch.php index cb90f5698..e2b806469 100644 --- a/src/Traits/InteractWithSearch.php +++ b/src/Traits/InteractWithSearch.php @@ -17,6 +17,9 @@ use Binaryk\LaravelRestify\Repositories\Repository; use Illuminate\Support\Collection; +/** + * @mixin Repository + */ trait InteractWithSearch { use AuthorizableModels; @@ -35,6 +38,22 @@ public static function withs(): array return static::$with ?? []; } + public static function collectWiths(RestifyRequest $request, Repository $repository): Collection + { + return collect(array_unique(array_merge( + $repository::withs(), + static::lazyLoadedFieldsRelationship($request, $repository), + ))); + } + + public static function lazyLoadedFieldsRelationship(RestifyRequest $request, Repository $repository): array + { + return $repository->collectFields($request) + ->filter(fn(Field $field) => $field->isLazy($request)) + ->map(fn(Field $field) => $field->getLazyRelationshipName()) + ->all(); + } + public static function related(): array { return static::$related ?? []; @@ -90,7 +109,7 @@ public static function collectSorts(RestifyRequest $request, Repository $reposit public static function collectFieldSorts(RestifyRequest $request, Repository $repository): Collection { return $repository->collectFields($request) - ->filter(fn (Field $field) => $field->isSortable($request)) + ->filter(fn(Field $field) => $field->isSortable($request)) ->map(function (Field $field) { $sortableFilter = new SortableFilter; $sortableFilter->setColumn($field->getAttribute()); @@ -129,7 +148,7 @@ public static function collectSearchables(RestifyRequest $request, Repository $r public static function collectFieldSearchables(RestifyRequest $request, Repository $repository): Collection { return $repository->collectFields($request) - ->filter(fn (Field $field) => $field->isSearchable($request)) + ->filter(fn(Field $field) => $field->isSearchable($request)) ->map(function (Field $field) use ($request, $repository) { $searchColumn = $field->getSearchColumn($request); if ($searchColumn instanceof SearchableFilter) { @@ -178,7 +197,7 @@ public static function collectMatches(RestifyRequest $request, Repository $repos public static function collectFieldMatches(RestifyRequest $request, Repository $repository): Collection { return $repository->collectFields($request) - ->filter(fn (Field $field) => $field->isMatchable($request)) + ->filter(fn(Field $field) => $field->isMatchable($request)) ->map(callback: function (Field $field) use ($request) { $matchColumn = $field->getMatchColumn($request); if ($matchColumn instanceof MatchFilter) { @@ -230,7 +249,7 @@ public static function collectFilters($type): Collection } return $type instanceof Filter - ? tap($type, fn ($filter) => $filter->column = $filter->column ?? $column) + ? tap($type, fn($filter) => $filter->column = $filter->column ?? $column) : tap(new $base, function (Filter $filter) use ($column, $type) { $filter->type = $type ? $type : 'value'; $filter->column = $column; diff --git a/tests/Feature/BelongsToJoinConfigTest.php b/tests/Feature/BelongsToJoinConfigTest.php new file mode 100644 index 000000000..95726311c --- /dev/null +++ b/tests/Feature/BelongsToJoinConfigTest.php @@ -0,0 +1,89 @@ + false]); + + $john = User::factory()->create([ + 'name' => 'John Doe', + ]); + + Post::factory(2)->create([ + 'edited_by' => $john->id, + ]); + + $otherUser = User::factory()->create([ + 'name' => 'Other User', + ]); + + Post::factory(1)->create([ + 'edited_by' => $otherUser->id, + ]); + + PostRepository::$related = [ + 'editor' => BelongsTo::make('editor', UserRepository::class)->searchable([ + 'users.name', + ]), + ]; + + $this->getJson(PostRepository::route(query: ['search' => 'John'])) + ->assertJsonCount(2, 'data'); + } + + public function test_belongs_to_search_works_with_joins_enabled(): void + { + // Enable JOINs (default) + config(['restify.search.use_joins_for_belongs_to' => true]); + + $john = User::factory()->create([ + 'name' => 'John Doe', + ]); + + Post::factory(2)->create([ + 'edited_by' => $john->id, + ]); + + $otherUser = User::factory()->create([ + 'name' => 'Other User', + ]); + + Post::factory(1)->create([ + 'edited_by' => $otherUser->id, + ]); + + PostRepository::$related = [ + 'editor' => BelongsTo::make('editor', UserRepository::class)->searchable([ + 'users.name', + ]), + ]; + + $this->getJson(PostRepository::route(query: ['search' => 'John'])) + ->assertJsonCount(2, 'data'); + } + + protected function tearDown(): void + { + // Reset to default + config(['restify.search.use_joins_for_belongs_to' => true]); + + parent::tearDown(); + } +} \ No newline at end of file diff --git a/tests/Fields/BelongsToFieldTest.php b/tests/Fields/BelongsToFieldTest.php index 6aa1ad59a..78baf7a72 100644 --- a/tests/Fields/BelongsToFieldTest.php +++ b/tests/Fields/BelongsToFieldTest.php @@ -220,15 +220,14 @@ public function test_belongs_to_could_choose_columns(): void 'user' => BelongsTo::make('user', UserRepository::class), ]); - $this->withoutExceptionHandling(); - $this->getJson(PostRepository::route($post, query: [ - 'include' => 'user[name,email]', + 'include' => 'user[name|email]', ])) ->assertJson( fn (AssertableJson $json) => $json ->has('data.relationships.user.attributes.name') - ->missing('data.relationships.user.attributes.email') + ->has('data.relationships.user.attributes.email') + ->missing('data.relationships.user.attributes.password') ->etc() ); } diff --git a/tests/Fields/FieldTest.php b/tests/Fields/FieldTest.php index f52389014..83c489a5a 100644 --- a/tests/Fields/FieldTest.php +++ b/tests/Fields/FieldTest.php @@ -563,6 +563,23 @@ public function test_field_sortable_method_is_fluent(): void $this->assertTrue($field->isSortable()); $this->assertEquals('Custom Label', $field->label); } + + public function test_field_can_be_marked_as_lazy(): void + { + $field = Field::make('tags'); + $request = new RestifyRequest; + + // Initially not lazy + $this->assertFalse($field->isLazy($request)); + $this->assertNull($field->getLazyRelationshipName()); + + // Mark as lazy with specific relationship name + $result = $field->lazy('tags'); + $this->assertTrue($field->isLazy($request)); + $this->assertEquals('tags', $field->getLazyRelationshipName()); + $this->assertInstanceOf(Field::class, $result); + $this->assertSame($field, $result); + } } class InvokableFill diff --git a/tests/Fields/LazyRelationshipTest.php b/tests/Fields/LazyRelationshipTest.php new file mode 100644 index 000000000..c739c9620 --- /dev/null +++ b/tests/Fields/LazyRelationshipTest.php @@ -0,0 +1,63 @@ +lazy('posts'); + $request = new RestifyRequest; + + // Test basic lazy functionality + $this->assertTrue($field->isLazy($request)); + $this->assertEquals('posts', $field->getLazyRelationshipName()); + + // Test fluent API + $this->assertInstanceOf(Field::class, $field); + } + + public function test_field_lazy_defaults_to_attribute_name() + { + $field = Field::make('posts')->lazy(); + $request = new RestifyRequest; + + $this->assertTrue($field->isLazy($request)); + $this->assertEquals('posts', $field->getLazyRelationshipName()); + } + + public function test_field_lazy_with_null_uses_attribute_name() + { + $field = Field::make('tags')->lazy(null); + $request = new RestifyRequest; + + $this->assertTrue($field->isLazy($request)); + $this->assertEquals('tags', $field->getLazyRelationshipName()); + } + + public function test_field_lazy_method_is_fluent() + { + $field = Field::make('profileTags') + ->lazy('tags') + ->label('Profile Tags'); + + $request = new RestifyRequest; + + $this->assertTrue($field->isLazy($request)); + $this->assertEquals('tags', $field->getLazyRelationshipName()); + $this->assertEquals('Profile Tags', $field->label); + } + + public function test_non_lazy_field_returns_false() + { + $field = Field::make('title'); + $request = new RestifyRequest; + + $this->assertFalse($field->isLazy($request)); + $this->assertNull($field->getLazyRelationshipName()); + } +} \ No newline at end of file From 5c8b0153a27cee55a659eb6528906cd399053c7a Mon Sep 17 00:00:00 2001 From: binaryk Date: Thu, 28 Aug 2025 12:57:00 +0000 Subject: [PATCH 2/5] Fix styling --- .../Concerns/CanLoadLazyRelationship.php | 2 +- src/Fields/EagerField.php | 8 +- src/Fields/Field.php | 2 +- src/Filters/SearchableFilter.php | 8 +- src/Filters/SearchablesCollection.php | 2 +- .../Controllers/RepositoryShowController.php | 2 +- src/Http/Controllers/RestResponse.php | 1 - src/Repositories/Repository.php | 78 +++++++++---------- .../Search/RepositorySearchService.php | 20 ++--- src/Traits/InteractWithSearch.php | 12 +-- tests/Feature/BelongsToJoinConfigTest.php | 14 ++-- tests/Fields/LazyRelationshipTest.php | 6 +- 12 files changed, 77 insertions(+), 78 deletions(-) diff --git a/src/Fields/Concerns/CanLoadLazyRelationship.php b/src/Fields/Concerns/CanLoadLazyRelationship.php index 17ebf0518..fd8f81eb6 100644 --- a/src/Fields/Concerns/CanLoadLazyRelationship.php +++ b/src/Fields/Concerns/CanLoadLazyRelationship.php @@ -21,7 +21,7 @@ public function lazy(string $relationshipName = null): self public function isLazy(RestifyRequest $request): bool { - return !is_null($this->lazyRelationshipName); + return ! is_null($this->lazyRelationshipName); } public function getLazyRelationshipName(): ?string diff --git a/src/Fields/EagerField.php b/src/Fields/EagerField.php index 67a893827..bd9d62797 100644 --- a/src/Fields/EagerField.php +++ b/src/Fields/EagerField.php @@ -45,7 +45,7 @@ public function __construct($attribute, ?string $parentRepository = null) if (is_null($parentRepository)) { $this->repositoryClass = tap(Restify::repositoryClassForKey(str($attribute)->pluralStudly()->kebab()->toString()), - fn($repository) => abort_unless($repository, 400, "Repository not found for the key [$attribute].")); + fn ($repository) => abort_unless($repository, 400, "Repository not found for the key [$attribute].")); } if (! isset($this->repositoryClass)) { @@ -61,9 +61,9 @@ public function __construct($attribute, ?string $parentRepository = null) public function authorize(Request $request) { return call_user_func( - [$this->repositoryClass, 'authorizedToUseRepository'], - $request - ) && parent::authorize($request); + [$this->repositoryClass, 'authorizedToUseRepository'], + $request + ) && parent::authorize($request); } public function resolve($repository, $attribute = null) diff --git a/src/Fields/Field.php b/src/Fields/Field.php index dcd9488c6..5cb019047 100644 --- a/src/Fields/Field.php +++ b/src/Fields/Field.php @@ -24,10 +24,10 @@ class Field extends OrganicField implements JsonSerializable, Matchable, Sortable { + use CanLoadLazyRelationship; use CanMatch; use CanSearch; use CanSort; - use CanLoadLazyRelationship; use FieldMcpSchemaDetection; use HasAction; use Make; diff --git a/src/Filters/SearchableFilter.php b/src/Filters/SearchableFilter.php index e3f7a5b9f..cf30d161c 100644 --- a/src/Filters/SearchableFilter.php +++ b/src/Filters/SearchableFilter.php @@ -42,7 +42,7 @@ public function filter(RestifyRequest $request, $query, $value) // Check if the attribute is already qualified (contains a dot) $qualifiedColumn = str_contains($attribute, '.') ? $attribute - : $relatedTable . '.' . $attribute; + : $relatedTable.'.'.$attribute; $query->orWhere($qualifiedColumn, $likeOperator, "%{$value}%"); }); @@ -57,9 +57,9 @@ public function filter(RestifyRequest $request, $query, $value) $ownerKey = $relation->getOwnerKeyName(); // Build the subquery: (SELECT column FROM related_table WHERE related_table.key = main_table.foreign_key LIMIT 1) - $qualifiedColumn = str_contains($attribute, '.') ? $attribute : $relatedTable . '.' . $attribute; - $localTableForeignKey = $this->repository->model()->getTable() . '.' . $foreignKey; - $relatedTableOwnerKey = $relatedTable . '.' . $ownerKey; + $qualifiedColumn = str_contains($attribute, '.') ? $attribute : $relatedTable.'.'.$attribute; + $localTableForeignKey = $this->repository->model()->getTable().'.'.$foreignKey; + $relatedTableOwnerKey = $relatedTable.'.'.$ownerKey; $subQuery->whereRaw( "(SELECT {$qualifiedColumn} FROM {$relatedTable} WHERE {$relatedTableOwnerKey} = {$localTableForeignKey} LIMIT 1) {$likeOperator} ?", diff --git a/src/Filters/SearchablesCollection.php b/src/Filters/SearchablesCollection.php index fd210b204..f9c2c84b9 100644 --- a/src/Filters/SearchablesCollection.php +++ b/src/Filters/SearchablesCollection.php @@ -138,7 +138,7 @@ public function qualifyColumns(Model $model): self public function onlyBelongsTo(): self { return $this->filter( - fn(SearchableFilter $filter) => $filter->hasBelongsTo() + fn (SearchableFilter $filter) => $filter->hasBelongsTo() ); } } diff --git a/src/Http/Controllers/RepositoryShowController.php b/src/Http/Controllers/RepositoryShowController.php index 4b21b137e..1b169a3c2 100644 --- a/src/Http/Controllers/RepositoryShowController.php +++ b/src/Http/Controllers/RepositoryShowController.php @@ -11,7 +11,7 @@ public function __invoke(RepositoryShowRequest $request): Response { $repository = $request->repository(); - return $request->repositoryWith(tap($request->modelQuery(), fn($query) => $repository::showQuery( + return $request->repositoryWith(tap($request->modelQuery(), fn ($query) => $repository::showQuery( $request, $repository::mainQuery($request, $query->with($repository::collectWiths($request, $repository)->all())) ))->with($repository::collectWiths($request, $repository)->all())->firstOrFail()) diff --git a/src/Http/Controllers/RestResponse.php b/src/Http/Controllers/RestResponse.php index d342bd346..612f34028 100644 --- a/src/Http/Controllers/RestResponse.php +++ b/src/Http/Controllers/RestResponse.php @@ -3,7 +3,6 @@ namespace Binaryk\LaravelRestify\Http\Controllers; use Binaryk\LaravelRestify\Contracts\RestifySearchable; -use Binaryk\LaravelRestify\MCP\Requests\McpRequest; use Binaryk\LaravelRestify\Repositories\Repository; use Binaryk\LaravelRestify\Repositories\RepositoryCollection; use Illuminate\Contracts\Pagination\Paginator; diff --git a/src/Repositories/Repository.php b/src/Repositories/Repository.php index 95723a8a8..11c246ba5 100644 --- a/src/Repositories/Repository.php +++ b/src/Repositories/Repository.php @@ -428,10 +428,10 @@ public function resolveShowAttributes(RestifyRequest $request) { $fields = $this->collectFields($request) ->forShow($request, $this) - ->filter(fn(Field $field) => $field->authorize($request)) - ->each(fn(Field $field) => $field->resolveForShow($this)) - ->map(fn(Field $field) => $field->serializeToValue($request)) - ->mapWithKeys(fn($value) => $value) + ->filter(fn (Field $field) => $field->authorize($request)) + ->each(fn (Field $field) => $field->resolveForShow($this)) + ->map(fn (Field $field) => $field->serializeToValue($request)) + ->mapWithKeys(fn ($value) => $value) ->all(); if ($this instanceof Mergeable) { @@ -496,17 +496,17 @@ public function resolveIndexAttributes($request) ->collectFields($request) ->when( $this->hasCustomColumns(), - fn(FieldCollection $fields) => $fields->inList($this->getColumns()) + fn (FieldCollection $fields) => $fields->inList($this->getColumns()) ) ->forIndex($request, $this) - ->filter(fn(Field $field) => $field->authorize($request)) + ->filter(fn (Field $field) => $field->authorize($request)) ->when( $this->eagerState, - fn($items) => $items->filter(fn(Field $field) => ! $field instanceof EagerField) + fn ($items) => $items->filter(fn (Field $field) => ! $field instanceof EagerField) ) - ->each(fn(Field $field) => $field->resolveForIndex($this)) - ->map(fn(Field $field) => $field->serializeToValue($request)) - ->mapWithKeys(fn($value) => $value) + ->each(fn (Field $field) => $field->resolveForIndex($this)) + ->map(fn (Field $field) => $field->serializeToValue($request)) + ->mapWithKeys(fn ($value) => $value) ->all(); } @@ -540,10 +540,10 @@ public function resolveShowPivots(RestifyRequest $request): array } return $pivots - ->filter(fn(Field $field) => $field->authorize($request)) - ->each(fn(Field $field) => $field->resolve($this)) - ->map(fn(Field $field) => $field->serializeToValue($request)) - ->mapWithKeys(fn($value) => $value) + ->filter(fn (Field $field) => $field->authorize($request)) + ->each(fn (Field $field) => $field->resolve($this)) + ->map(fn (Field $field) => $field->serializeToValue($request)) + ->mapWithKeys(fn ($value) => $value) ->all(); } @@ -569,8 +569,8 @@ public function resolveRelationships($request): array ->forRequest($request, $this) ->mapIntoRelated($request, $this) ->unserialized($request, $this) - ->when($this->isForMcp(), fn(RelatedCollection $collection) => $collection->forMcp($request, $this)) - ->map(fn(Related $related) => $related->resolve($request, $this)->getValue()) + ->when($this->isForMcp(), fn (RelatedCollection $collection) => $collection->forMcp($request, $this)) + ->map(fn (Related $related) => $related->resolve($request, $this)->getValue()) ->map(function (mixed $items) { if ($items instanceof Collection) { return $items->filter()->values(); @@ -636,13 +636,13 @@ public function indexAsArray(RestifyRequest $request): array return $repository->authorizedToShow($request); })->values(); - $data = $items->map(fn(self $repository) => $repository->serializeForIndex($request)); + $data = $items->map(fn (self $repository) => $repository->serializeForIndex($request)); return $this->filter([ 'meta' => $this->when( $meta = $this->resolveIndexMainMeta( $request, - $models = $items->map(fn(self $repository) => $repository->resource), + $models = $items->map(fn (self $repository) => $repository->resource), [ 'current_page' => $paginator->currentPage(), 'from' => $paginator->firstItem(), @@ -728,14 +728,14 @@ public function store(RestifyRequest $request) } } - $fields->each(fn(Field $field) => $field->invokeAfter($request, $this->resource)); + $fields->each(fn (Field $field) => $field->invokeAfter($request, $this->resource)); $this ->collectFields($request) ->forStore($request, $this) ->withActions($request, $this) ->authorizedStore($request) - ->each(fn(Field $field) => $field->actionHandler->handle($request, $this->resource)); + ->each(fn (Field $field) => $field->actionHandler->handle($request, $this->resource)); }); if (method_exists(static::class, 'stored')) { @@ -764,14 +764,14 @@ public function storeBulk(RepositoryStoreBulkRequest $request) $this->resource->save(); - $fields->each(fn(Field $field) => $field->invokeAfter($request, $this->resource)); + $fields->each(fn (Field $field) => $field->invokeAfter($request, $this->resource)); $this ->collectFields($request) ->forStoreBulk($request, $this) ->withActions($request, $this, $row) ->authorizedUpdateBulk($request) - ->each(fn(Field $field) => $field->actionHandler->handle($request, $this->resource, $row)); + ->each(fn (Field $field) => $field->actionHandler->handle($request, $this->resource, $row)); return $this->resource; }); @@ -798,7 +798,7 @@ public function update(RestifyRequest $request, $repositoryId) return $fields; })->each( - fn(Field $field) => $field->invokeAfter($request, $this->resource) + fn (Field $field) => $field->invokeAfter($request, $this->resource) ); $this @@ -806,7 +806,7 @@ public function update(RestifyRequest $request, $repositoryId) ->forUpdate($request, $this) ->withActions($request, $this) ->authorizedUpdate($request) - ->each(fn(Field $field) => $field->actionHandler->handle($request, $this->resource)); + ->each(fn (Field $field) => $field->actionHandler->handle($request, $this->resource)); return data($this->serializeForShow($request)); } @@ -818,7 +818,7 @@ public function patch(RestifyRequest $request, $repositoryId) DB::transaction(function () use ($request, $keys) { $fields = $this->collectFields($request) ->filter( - fn(Field $field) => in_array($field->attribute, $keys), + fn (Field $field) => in_array($field->attribute, $keys), ) ->forUpdate($request, $this) ->withoutActions($request, $this) @@ -837,18 +837,18 @@ public function patch(RestifyRequest $request, $repositoryId) return $fields; })->each( - fn(Field $field) => $field->invokeAfter($request, $this->resource) + fn (Field $field) => $field->invokeAfter($request, $this->resource) ); $this ->collectFields($request) ->filter( - fn(Field $field) => in_array($field->attribute, $keys), + fn (Field $field) => in_array($field->attribute, $keys), ) ->forUpdate($request, $this) ->withActions($request, $this) ->authorizedPatch($request) - ->each(fn(Field $field) => $field->actionHandler->handle($request, $this->resource)); + ->each(fn (Field $field) => $field->actionHandler->handle($request, $this->resource)); return data($this->serializeForShow($request)); } @@ -869,7 +869,7 @@ public function updateBulk(RestifyRequest $request, $repositoryId, int $row) ->forUpdateBulk($request, $this) ->withActions($request, $this, $row) ->authorizedUpdateBulk($request) - ->each(fn(Field $field) => $field->actionHandler->handle($request, $this->resource, $row)); + ->each(fn (Field $field) => $field->actionHandler->handle($request, $this->resource, $row)); return response()->json(); } @@ -894,7 +894,7 @@ public function attach(RestifyRequest $request, $repositoryId, Collection $pivot $eagerField = $this->authorizeBelongsToMany($request)->belongsToManyField($request); DB::transaction(function () use ($request, $pivots, $eagerField) { - $fields = $eagerField->collectPivotFields()->filter(fn( + $fields = $eagerField->collectPivotFields()->filter(fn ( $pivotField ) => $request->has($pivotField->attribute))->values(); @@ -938,7 +938,7 @@ public function detach(RestifyRequest $request, $repositoryId, Collection $pivot $deleted = DB::transaction(function () use ($pivots, $eagerField, $request) { return $pivots - ->map(fn($pivot) => $eagerField->authorizeToDetach($request, $pivot) && $pivot->delete()); + ->map(fn ($pivot) => $eagerField->authorizeToDetach($request, $pivot) && $pivot->delete()); }); return data($deleted, 204); @@ -981,7 +981,7 @@ public function allowToAttach(RestifyRequest $request, Collection $attachers): s { $methodGuesser = 'attach'.Str::studly($request->relatedRepository); - $attachers->each(fn($model) => $this->authorizeToAttach($request, $methodGuesser, $model)); + $attachers->each(fn ($model) => $this->authorizeToAttach($request, $methodGuesser, $model)); return $this; } @@ -999,7 +999,7 @@ public function allowToDetach(RestifyRequest $request, Collection $attachers): s { $methodGuesser = 'detach'.Str::studly($request->relatedRepository); - $attachers->each(fn($model) => $this->authorizeToDetach($request, $methodGuesser, $model)); + $attachers->each(fn ($model) => $this->authorizeToDetach($request, $methodGuesser, $model)); return $this; } @@ -1103,7 +1103,7 @@ public function serializeForShow(RestifyRequest $request): array $this->request = $request; return $this->filter([ - 'id' => $this->when(optional($this->resource)?->getKey(), fn() => $this->getId($request)), + 'id' => $this->when(optional($this->resource)?->getKey(), fn () => $this->getId($request)), 'type' => $this->when($type = $this->getType($request), $type), 'attributes' => $request->isShowRequest() ? $this->resolveShowAttributes($request) : $this->resolveIndexAttributes($request), 'relationships' => $this->when(value($related = $this->resolveRelationships($request)), $related), @@ -1171,7 +1171,7 @@ private function modelAttributes(?Request $request = null): Collection */ protected static function fillFields(RestifyRequest $request, Model $model, Collection $fields) { - return $fields->map(fn(Field $field) => $field->fillAttribute($request, $model)); + return $fields->map(fn (Field $field) => $field->fillAttribute($request, $model)); } protected static function fillBulkFields( @@ -1239,14 +1239,14 @@ public function restifyjsSerialize(RestifyRequest $request): array 'match' => static::collectFilters('matches'), 'searchables' => static::collectFilters('searchables'), 'actions' => $this->resolveActions($request) - ->filter(fn(mixed $action) => $action instanceof Action) - ->filter(fn(Action $action) => $action->isShownOnIndex( + ->filter(fn (mixed $action) => $action instanceof Action) + ->filter(fn (Action $action) => $action->isShownOnIndex( $request, $this ))->values(), 'getters' => $this->resolveGetters($request) - ->filter(fn(mixed $action) => $action instanceof Getter) - ->filter(fn(Getter $action) => $action->isShownOnIndex( + ->filter(fn (mixed $action) => $action instanceof Getter) + ->filter(fn (Getter $action) => $action->isShownOnIndex( $request, $this ))->values(), diff --git a/src/Services/Search/RepositorySearchService.php b/src/Services/Search/RepositorySearchService.php index ae83b217b..1b5fec66a 100644 --- a/src/Services/Search/RepositorySearchService.php +++ b/src/Services/Search/RepositorySearchService.php @@ -36,9 +36,9 @@ public function search(RestifyRequest $request, Repository $repository): Builder $shouldUseScout ? $this->prepareRelations($request, $scoutQuery ?? $repository::query($request)) : $this->prepareSearchFields( - $request, - $this->prepareRelations($request, $scoutQuery ?? $repository::query($request)), - ), + $request, + $this->prepareRelations($request, $scoutQuery ?? $repository::query($request)), + ), ); $query = $this->applyFilters($request, $repository, $query); @@ -86,7 +86,7 @@ public function prepareRelations(RestifyRequest $request, Builder|Relation $quer $eager = ($this->repository)::collectRelated() ->forRequest($request, $this->repository) ->map( - fn($relation) => $relation instanceof EagerField + fn ($relation) => $relation instanceof EagerField ? $relation->relation : $relation ) @@ -98,8 +98,8 @@ public function prepareRelations(RestifyRequest $request, Builder|Relation $quer return $query; } - $filtered = collect($request->related()->makeTree())->filter(fn(string $relationships) => in_array( - str($relationships)->whenContains('.', fn(Stringable $string) => $string->before('.'))->toString(), + $filtered = collect($request->related()->makeTree())->filter(fn (string $relationships) => in_array( + str($relationships)->whenContains('.', fn (Stringable $string) => $string->before('.'))->toString(), $eager, true, ))->filter(function ($relation) use ($query) { @@ -158,14 +158,14 @@ public function prepareSearchFields(RestifyRequest $request, $query) protected function applyIndexQuery(RestifyRequest $request, Repository $repository) { if ($request->isIndexRequest() || $request->isGlobalRequest()) { - return fn($query) => $repository::indexQuery($request, $query); + return fn ($query) => $repository::indexQuery($request, $query); } if ($request->isShowRequest()) { - return fn($query) => $repository::showQuery($request, $query); + return fn ($query) => $repository::showQuery($request, $query); } - return fn($query) => $query; + return fn ($query) => $query; } public function initializeQueryUsingScout(RestifyRequest $request, Repository $repository): Builder @@ -193,7 +193,7 @@ function ($scoutBuilder) use ($repository, $request) { protected function applyMainQuery(RestifyRequest $request, Repository $repository): callable { - return fn($query) => $repository::mainQuery($request, $query->with($repository::collectWiths( + return fn ($query) => $repository::mainQuery($request, $query->with($repository::collectWiths( $request, $repository )->all())); } diff --git a/src/Traits/InteractWithSearch.php b/src/Traits/InteractWithSearch.php index e2b806469..5b3048407 100644 --- a/src/Traits/InteractWithSearch.php +++ b/src/Traits/InteractWithSearch.php @@ -49,8 +49,8 @@ public static function collectWiths(RestifyRequest $request, Repository $reposit public static function lazyLoadedFieldsRelationship(RestifyRequest $request, Repository $repository): array { return $repository->collectFields($request) - ->filter(fn(Field $field) => $field->isLazy($request)) - ->map(fn(Field $field) => $field->getLazyRelationshipName()) + ->filter(fn (Field $field) => $field->isLazy($request)) + ->map(fn (Field $field) => $field->getLazyRelationshipName()) ->all(); } @@ -109,7 +109,7 @@ public static function collectSorts(RestifyRequest $request, Repository $reposit public static function collectFieldSorts(RestifyRequest $request, Repository $repository): Collection { return $repository->collectFields($request) - ->filter(fn(Field $field) => $field->isSortable($request)) + ->filter(fn (Field $field) => $field->isSortable($request)) ->map(function (Field $field) { $sortableFilter = new SortableFilter; $sortableFilter->setColumn($field->getAttribute()); @@ -148,7 +148,7 @@ public static function collectSearchables(RestifyRequest $request, Repository $r public static function collectFieldSearchables(RestifyRequest $request, Repository $repository): Collection { return $repository->collectFields($request) - ->filter(fn(Field $field) => $field->isSearchable($request)) + ->filter(fn (Field $field) => $field->isSearchable($request)) ->map(function (Field $field) use ($request, $repository) { $searchColumn = $field->getSearchColumn($request); if ($searchColumn instanceof SearchableFilter) { @@ -197,7 +197,7 @@ public static function collectMatches(RestifyRequest $request, Repository $repos public static function collectFieldMatches(RestifyRequest $request, Repository $repository): Collection { return $repository->collectFields($request) - ->filter(fn(Field $field) => $field->isMatchable($request)) + ->filter(fn (Field $field) => $field->isMatchable($request)) ->map(callback: function (Field $field) use ($request) { $matchColumn = $field->getMatchColumn($request); if ($matchColumn instanceof MatchFilter) { @@ -249,7 +249,7 @@ public static function collectFilters($type): Collection } return $type instanceof Filter - ? tap($type, fn($filter) => $filter->column = $filter->column ?? $column) + ? tap($type, fn ($filter) => $filter->column = $filter->column ?? $column) : tap(new $base, function (Filter $filter) use ($column, $type) { $filter->type = $type ? $type : 'value'; $filter->column = $column; diff --git a/tests/Feature/BelongsToJoinConfigTest.php b/tests/Feature/BelongsToJoinConfigTest.php index 95726311c..155dd0149 100644 --- a/tests/Feature/BelongsToJoinConfigTest.php +++ b/tests/Feature/BelongsToJoinConfigTest.php @@ -11,7 +11,7 @@ /** * Test cases for the configurable JOIN optimization feature for BelongsTo relationship searches. - * + * * This test ensures that both JOIN-based (optimized) and subquery-based (legacy) approaches * work correctly based on the configuration setting. */ @@ -21,7 +21,7 @@ public function test_belongs_to_search_works_with_joins_disabled(): void { // Disable JOINs config(['restify.search.use_joins_for_belongs_to' => false]); - + $john = User::factory()->create([ 'name' => 'John Doe', ]); @@ -47,12 +47,12 @@ public function test_belongs_to_search_works_with_joins_disabled(): void $this->getJson(PostRepository::route(query: ['search' => 'John'])) ->assertJsonCount(2, 'data'); } - + public function test_belongs_to_search_works_with_joins_enabled(): void { // Enable JOINs (default) config(['restify.search.use_joins_for_belongs_to' => true]); - + $john = User::factory()->create([ 'name' => 'John Doe', ]); @@ -78,12 +78,12 @@ public function test_belongs_to_search_works_with_joins_enabled(): void $this->getJson(PostRepository::route(query: ['search' => 'John'])) ->assertJsonCount(2, 'data'); } - + protected function tearDown(): void { // Reset to default config(['restify.search.use_joins_for_belongs_to' => true]); - + parent::tearDown(); } -} \ No newline at end of file +} diff --git a/tests/Fields/LazyRelationshipTest.php b/tests/Fields/LazyRelationshipTest.php index c739c9620..72d0011f7 100644 --- a/tests/Fields/LazyRelationshipTest.php +++ b/tests/Fields/LazyRelationshipTest.php @@ -16,7 +16,7 @@ public function test_field_lazy_basic_functionality() // Test basic lazy functionality $this->assertTrue($field->isLazy($request)); $this->assertEquals('posts', $field->getLazyRelationshipName()); - + // Test fluent API $this->assertInstanceOf(Field::class, $field); } @@ -46,7 +46,7 @@ public function test_field_lazy_method_is_fluent() ->label('Profile Tags'); $request = new RestifyRequest; - + $this->assertTrue($field->isLazy($request)); $this->assertEquals('tags', $field->getLazyRelationshipName()); $this->assertEquals('Profile Tags', $field->label); @@ -60,4 +60,4 @@ public function test_non_lazy_field_returns_false() $this->assertFalse($field->isLazy($request)); $this->assertNull($field->getLazyRelationshipName()); } -} \ No newline at end of file +} From b7508e38dfc055fb333b96b2a19e8da8aca63d6d Mon Sep 17 00:00:00 2001 From: binaryk Date: Thu, 28 Aug 2025 13:01:37 +0000 Subject: [PATCH 3/5] Fix styling --- src/Filters/SearchableFilter.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Filters/SearchableFilter.php b/src/Filters/SearchableFilter.php index 19806548c..d2fcc7b98 100644 --- a/src/Filters/SearchableFilter.php +++ b/src/Filters/SearchableFilter.php @@ -42,7 +42,7 @@ public function filter(RestifyRequest $request, $query, $value) // Check if the attribute is already qualified (contains a dot) $qualifiedColumn = str_contains($attribute, '.') ? $attribute - : $relatedTable . '.' . $attribute; + : $relatedTable.'.'.$attribute; $query->orWhere($qualifiedColumn, $likeOperator, "%{$value}%"); }); From c0f7b3d21aada82a7146e97494f9cbef58c39f10 Mon Sep 17 00:00:00 2001 From: Eduard Lupacescu Date: Thu, 28 Aug 2025 16:32:17 +0300 Subject: [PATCH 4/5] fix: wip --- RELEASE.md | 15 +++++ docs-v2/content/en/api/relations.md | 23 +++++++ src/Fields/BelongsTo.php | 12 ++++ src/Filters/SearchableFilter.php | 67 +++++++++++++++---- .../Search/RepositorySearchService.php | 1 + 5 files changed, 105 insertions(+), 13 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index 2f5f69377..8fdacfb11 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -56,6 +56,21 @@ New and improved field methods with flexible signatures: - **`matchable()`** - Various match types and advanced filtering scenarios - **`sortable()`** - Custom columns and conditional sorting +#### Custom Search Callbacks for BelongsTo Relations + +BelongsTo fields now support custom search callbacks for complete control over search behavior: + +```php +BelongsTo::make('user')->searchable(function ($query, $request, $value, $field, $repository) { + return $query->whereHas('user', function ($q) use ($value) { + $q->where('name', 'ilike', "%{$value}%") + ->orWhere('email', 'ilike', "%{$value}%"); + }); +}) +``` + +The callback receives all necessary parameters with the query as the first parameter for maximum flexibility. + ๐Ÿ“– **[Field Methods Documentation โ†’](docs-v2/content/en/api/fields.md)** ### โš ๏ธ Breaking Changes diff --git a/docs-v2/content/en/api/relations.md b/docs-v2/content/en/api/relations.md index 009d4b803..a5aeeb97c 100644 --- a/docs-v2/content/en/api/relations.md +++ b/docs-v2/content/en/api/relations.md @@ -344,6 +344,29 @@ $isSearchable = $field->isSearchable(); // true $attributes = $field->getSearchables(); // ['name'] ``` +#### Custom Search Callbacks + +For advanced search scenarios, you can provide a custom callback to completely control the search behavior: + +```php +BelongsTo::make('user')->searchable(function ($query, $request, $value, $field, $repository) { + return $query->whereHas('user', function ($q) use ($value) { + $q->where('name', 'ilike', "%{$value}%") + ->orWhere('email', 'ilike', "%{$value}%") + ->orWhere('phone', 'like', "%{$value}%"); + }); +}) +``` + +The callback receives the following parameters: +- `$query` - The main query builder instance +- `$request` - The current RestifyRequest instance +- `$value` - The search value from the request +- `$field` - The BelongsTo field instance +- `$repository` - The current repository instance + +This approach provides maximum flexibility for complex search requirements while maintaining the same API interface. + ## HasOne The `HasOne` field corresponds to a `hasOne` Eloquent relationship. diff --git a/src/Fields/BelongsTo.php b/src/Fields/BelongsTo.php index 6f8393879..a0dc252cd 100644 --- a/src/Fields/BelongsTo.php +++ b/src/Fields/BelongsTo.php @@ -14,6 +14,8 @@ class BelongsTo extends EagerField implements Sortable public ?array $searchablesAttributes = null; + public ?\Closure $searchableCallback = null; + public function fillAttribute(RestifyRequest $request, $model, ?int $bulkRow = null) { /** * @var Model $relatedModel */ @@ -58,6 +60,12 @@ public function searchable(...$attributes): self return $this; } + if (count($attributes) === 1 && is_callable($attributes[0])) { + $this->searchableCallback = $attributes[0]; + + return $this; + } + // If it's relationship-specific multiple attributes (all strings), use BelongsTo behavior if (count($attributes) > 1 && collect($attributes)->every(fn ($attr) => is_string($attr))) { $this->searchablesAttributes = collect($attributes)->flatten()->all(); @@ -83,6 +91,10 @@ public function searchable(...$attributes): self */ public function isSearchable(?RestifyRequest $request = null): bool { + if (is_callable($this->searchableCallback)) { + return true; + } + return ! is_null($this->searchablesAttributes) || parent::isSearchable($request); } diff --git a/src/Filters/SearchableFilter.php b/src/Filters/SearchableFilter.php index 19806548c..1693ddc0c 100644 --- a/src/Filters/SearchableFilter.php +++ b/src/Filters/SearchableFilter.php @@ -31,6 +31,18 @@ public function filter(RestifyRequest $request, $query, $value) return $query; } + // Check if there's a custom searchable callback + if (is_callable($this->belongsToField->searchableCallback)) { + return call_user_func( + $this->belongsToField->searchableCallback, + $query, + $request, + $value, + $this->belongsToField, + $this->repository + ); + } + // Check if JOINs are enabled in config if (config('restify.search.use_joins_for_belongs_to', false)) { // JOINs are applied at the service level, so we just need to apply search conditions @@ -38,27 +50,56 @@ public function filter(RestifyRequest $request, $query, $value) $relatedTable = $relatedModel->getTable(); // Apply search conditions using qualified column names from the joined table - collect($this->belongsToField->getSearchables())->each(function (string $attribute) use ($query, $likeOperator, $value, $relatedTable) { + collect($this->belongsToField->getSearchables())->each(function (string $attribute) use ($query, $likeOperator, $value, $relatedTable, $connectionType) { // Check if the attribute is already qualified (contains a dot) $qualifiedColumn = str_contains($attribute, '.') ? $attribute : $relatedTable . '.' . $attribute; - $query->orWhere($qualifiedColumn, $likeOperator, "%{$value}%"); + if (! config('restify.search.case_sensitive')) { + $upper = strtoupper($value); + + $columnExpression = $connectionType === 'pgsql' + ? "UPPER({$qualifiedColumn}::text)" + : "UPPER({$qualifiedColumn})"; + + $query->orWhereRaw("{$columnExpression} LIKE ?", ['%'.$upper.'%']); + } else { + $query->orWhere($qualifiedColumn, $likeOperator, "%{$value}%"); + } }); } else { // Use the original subquery approach when JOINs are disabled - collect($this->belongsToField->getSearchables())->each(function (string $attribute) use ($query, $likeOperator, $value) { - $query->orWhere( - $this->belongsToField->getRelatedModel($this->repository)::select($attribute) - ->whereColumn( - $this->belongsToField->getQualifiedKey($this->repository), - $this->belongsToField->getRelatedKey($this->repository) - ) - ->take(1), - $likeOperator, - "%{$value}%" - ); + collect($this->belongsToField->getSearchables())->each(function (string $attribute) use ($query, $likeOperator, $value, $connectionType) { + if (! config('restify.search.case_sensitive')) { + $upper = strtoupper($value); + + $columnExpression = $connectionType === 'pgsql' + ? "UPPER({$attribute}::text)" + : "UPPER({$attribute})"; + + $query->orWhere( + $this->belongsToField->getRelatedModel($this->repository)::selectRaw($columnExpression) + ->whereColumn( + $this->belongsToField->getQualifiedKey($this->repository), + $this->belongsToField->getRelatedKey($this->repository) + ) + ->take(1), + 'like', + "%{$upper}%" + ); + } else { + $query->orWhere( + $this->belongsToField->getRelatedModel($this->repository)::select($attribute) + ->whereColumn( + $this->belongsToField->getQualifiedKey($this->repository), + $this->belongsToField->getRelatedKey($this->repository) + ) + ->take(1), + $likeOperator, + "%{$value}%" + ); + } }); } diff --git a/src/Services/Search/RepositorySearchService.php b/src/Services/Search/RepositorySearchService.php index 1b5fec66a..738003d86 100644 --- a/src/Services/Search/RepositorySearchService.php +++ b/src/Services/Search/RepositorySearchService.php @@ -272,6 +272,7 @@ private function applyBelongsToJoins( SearchablesCollection $searchablesCollection, RestifyRequest $request ): void { + ray('Applying BelongsTo JOINs for searchables')->blue(); $searchablesCollection ->onlyBelongsTo() ->each(function (SearchableFilter $searchable) use ($query, $request) { From 2a10031732847f93bb6d557b83bfc0a853b2335d Mon Sep 17 00:00:00 2001 From: Eduard Lupacescu Date: Thu, 28 Aug 2025 16:33:47 +0300 Subject: [PATCH 5/5] fix: wip --- src/Fields/BelongsTo.php | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/Fields/BelongsTo.php b/src/Fields/BelongsTo.php index a0dc252cd..847c52b4c 100644 --- a/src/Fields/BelongsTo.php +++ b/src/Fields/BelongsTo.php @@ -60,6 +60,15 @@ public function searchable(...$attributes): self return $this; } + // If parent set a simple string column, also set it in searchablesAttributes for consistency + if (count($attributes) === 1 && is_string($attributes[0])) { + $this->searchablesAttributes = [$attributes[0]]; + + parent::searchable($attributes[0]); + + return $this; + } + if (count($attributes) === 1 && is_callable($attributes[0])) { $this->searchableCallback = $attributes[0]; @@ -78,11 +87,6 @@ public function searchable(...$attributes): self // For single attribute or complex cases (closures, filters), use parent behavior parent::searchable(...$attributes); - // If parent set a simple string column, also set it in searchablesAttributes for consistency - if (count($attributes) === 1 && is_string($attributes[0])) { - $this->searchablesAttributes = [$attributes[0]]; - } - return $this; }