diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 000000000..8fdacfb11 --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,107 @@ +# 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 + +#### 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 + +#### 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/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..847c52b4c 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,21 @@ 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]; + + 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(); @@ -70,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; } @@ -83,6 +95,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/Fields/Concerns/CanLoadLazyRelationship.php b/src/Fields/Concerns/CanLoadLazyRelationship.php new file mode 100644 index 000000000..fd8f81eb6 --- /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/Field.php b/src/Fields/Field.php index 4e1dab7b2..5cb019047 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; @@ -23,6 +24,7 @@ class Field extends OrganicField implements JsonSerializable, Matchable, Sortable { + use CanLoadLazyRelationship; use CanMatch; use CanSearch; use CanSort; 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 12e034e78..3ceaffcef 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,27 +27,81 @@ 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 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 + $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, $connectionType) { + // Check if the attribute is already qualified (contains a dot) + $qualifiedColumn = str_contains($attribute, '.') + ? $attribute + : $relatedTable.'.'.$attribute; + + 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, $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}%" + ); + } + }); + } return $query; } @@ -90,4 +144,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..f9c2c84b9 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..1b169a3c2 100644 --- a/src/Http/Controllers/RepositoryShowController.php +++ b/src/Http/Controllers/RepositoryShowController.php @@ -13,8 +13,8 @@ public function __invoke(RepositoryShowRequest $request): Response 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/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/Services/Search/RepositorySearchService.php b/src/Services/Search/RepositorySearchService.php index 514d02723..738003d86 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; @@ -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(); @@ -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,62 @@ 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 { + ray('Applying BelongsTo JOINs for searchables')->blue(); + $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..5b3048407 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 ?? []; diff --git a/tests/Feature/BelongsToJoinConfigTest.php b/tests/Feature/BelongsToJoinConfigTest.php new file mode 100644 index 000000000..155dd0149 --- /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(); + } +} 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..72d0011f7 --- /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()); + } +}