Skip to content

Commit 79d8803

Browse files
committed
perf(search): Add alternative search using joins for nested relationships
1 parent 3b1a54b commit 79d8803

File tree

6 files changed

+1085
-23
lines changed

6 files changed

+1085
-23
lines changed

config/restify.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,19 @@
132132
| Specify either the search should be case-sensitive or not.
133133
*/
134134
'case_sensitive' => true,
135+
136+
/*
137+
|--------------------------------------------------------------------------
138+
| Search Optimization
139+
|--------------------------------------------------------------------------
140+
|
141+
| When enabled, search queries on related fields will use JOINs instead of
142+
| subqueries for better performance. This only affects searchable BelongsTo
143+
| relationships, not direct field searches on the main model.
144+
|
145+
| Set to false to use the legacy subquery approach for backward compatibility.
146+
*/
147+
'use_joins' => env('RESTIFY_SEARCH_USE_JOINS', false),
135148
],
136149

137150
'repositories' => [

docs/eager-loading-optimization.md

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
# Search Performance Optimization
2+
3+
Laravel Restify includes an optional search performance optimization that replaces inefficient subqueries with JOIN-based searches for related fields.
4+
5+
## Overview
6+
7+
By default, when searching through BelongsTo relationship fields, Laravel Restify uses subqueries which can be slow for large datasets:
8+
9+
```sql
10+
-- Default behavior (subqueries)
11+
SELECT * FROM `invoices` WHERE (
12+
UPPER(`invoices`.`gross_amount`) LIKE '%CSC%'
13+
OR (SELECT `vendors`.`code` FROM `vendors` WHERE `vendors`.`id` = `invoices`.`vendor_id` LIMIT 1) LIKE '%csc%'
14+
OR (SELECT `vendors`.`name` FROM `vendors` WHERE `vendors`.`id` = `invoices`.`vendor_id` LIMIT 1) LIKE '%csc%'
15+
)
16+
```
17+
18+
With JOIN optimization enabled, these become efficient JOIN-based queries:
19+
20+
```sql
21+
-- Optimized behavior (JOINs)
22+
SELECT invoices.* FROM `invoices`
23+
LEFT JOIN `vendors` AS vendors_for_vendor ON invoices.vendor_id = vendors_for_vendor.id
24+
WHERE (
25+
UPPER(invoices.gross_amount) LIKE '%CSC%'
26+
OR UPPER(vendors_for_vendor.code) LIKE '%CSC%'
27+
OR UPPER(vendors_for_vendor.name) LIKE '%CSC%'
28+
)
29+
```
30+
31+
## Configuration
32+
33+
### Enabling JOIN Optimization
34+
35+
Add the following to your `.env` file:
36+
37+
```env
38+
RESTIFY_SEARCH_USE_JOINS=true
39+
```
40+
41+
Or configure it directly in `config/restify.php`:
42+
43+
```php
44+
'search' => [
45+
'case_sensitive' => true,
46+
'use_joins' => true, // Enable JOIN optimization
47+
],
48+
```
49+
50+
### Default Behavior
51+
52+
**By default, JOIN optimization is disabled** to maintain backward compatibility. The system will continue using subqueries until explicitly enabled.
53+
54+
## When JOINs Are Used
55+
56+
JOIN optimization is **only applied when**:
57+
58+
1. ✅ The `restify.search.use_joins` config is set to `true`
59+
2. ✅ You're searching through a **BelongsTo relationship** field
60+
3. ✅ The relationship field is marked as **searchable**
61+
62+
JOIN optimization is **NOT applied for**:
63+
64+
- ❌ Direct field searches on the main model
65+
- ❌ HasMany or other relationship types
66+
- ❌ When the config flag is disabled (default)
67+
68+
## Repository Configuration
69+
70+
No changes are needed to your existing repository configuration. The optimization works with your current setup:
71+
72+
```php
73+
class InvoiceRepository extends Repository
74+
{
75+
public static function searchables(): array
76+
{
77+
return [
78+
'gross_amount', // Direct field - no JOIN needed
79+
'number', // Direct field - no JOIN needed
80+
];
81+
}
82+
83+
public static function related(): array
84+
{
85+
return [
86+
'vendor' => BelongsTo::make('vendor', VendorRepository::class)->searchable([
87+
'vendors.code', // Will use JOIN when optimization enabled
88+
'vendors.name', // Will use JOIN when optimization enabled
89+
]),
90+
];
91+
}
92+
}
93+
```
94+
95+
## Performance Benefits
96+
97+
### Query Efficiency
98+
- **Eliminates N+1 subquery problems** - Each related field search no longer creates separate subqueries
99+
- **Improved query performance** - JOINs are typically much faster than correlated subqueries
100+
- **Reduced database load** - Single query instead of multiple subqueries per search term
101+
102+
### Eager Loading Optimization
103+
When JOIN optimization is enabled, the system automatically:
104+
- **Detects already-joined relationships**
105+
- **Excludes them from eager loading** to prevent duplicate queries
106+
- **Maintains data integrity** while improving performance
107+
108+
### Example Performance Impact
109+
110+
**Before Optimization (3 separate subqueries)**:
111+
```sql
112+
-- Main query + 2 subqueries for each search term
113+
SELECT * FROM invoices WHERE (
114+
gross_amount LIKE '%term%' OR
115+
(SELECT code FROM vendors WHERE...) LIKE '%term%' OR
116+
(SELECT name FROM vendors WHERE...) LIKE '%term%'
117+
)
118+
```
119+
120+
**After Optimization (1 JOIN query)**:
121+
```sql
122+
-- Single query with JOIN
123+
SELECT invoices.* FROM invoices
124+
LEFT JOIN vendors AS vendors_for_vendor ON invoices.vendor_id = vendors_for_vendor.id
125+
WHERE (
126+
gross_amount LIKE '%term%' OR
127+
vendors_for_vendor.code LIKE '%term%' OR
128+
vendors_for_vendor.name LIKE '%term%'
129+
)
130+
```
131+
132+
## Backward Compatibility
133+
134+
This feature is **100% backward compatible**:
135+
136+
- **Default behavior unchanged** - Existing applications continue working without any changes
137+
- **Opt-in optimization** - You must explicitly enable JOIN optimization
138+
- **No breaking changes** - All existing repository configurations work as before
139+
- **Gradual adoption** - Enable per environment (dev/staging first, then production)
140+
141+
## Best Practices
142+
143+
### When to Enable
144+
-**Large datasets** with frequent relationship searches
145+
-**Performance-critical applications** where search speed matters
146+
-**Well-tested environments** where you can validate the behavior
147+
148+
### When to Keep Disabled
149+
-**Small datasets** where performance difference is negligible
150+
-**Legacy systems** where you want to maintain exact query behavior
151+
-**Complex relationship setups** that need specific subquery behavior
152+
153+
### Testing Strategy
154+
1. **Enable in development/staging first**
155+
2. **Run your existing test suite** to ensure no regressions
156+
3. **Monitor query performance** before and after
157+
4. **Gradually roll out to production**
158+
159+
## Troubleshooting
160+
161+
### Common Issues
162+
163+
**Q: JOINs aren't being used even though the config is enabled**
164+
A: Verify that:
165+
- The searchable fields are on BelongsTo relationships (not direct model fields)
166+
- The relationship is properly configured with `->searchable([...])`
167+
- Cache has been cleared after config changes
168+
169+
**Q: Getting different search results after enabling JOINs**
170+
A: This could indicate:
171+
- Multi-tenant constraints that were handled differently in subqueries
172+
- Complex relationship setups that need adjustment
173+
- Consider disabling the optimization for that specific use case
174+
175+
**Q: Performance didn't improve as expected**
176+
A: Check that:
177+
- You have proper database indexes on JOIN columns
178+
- The dataset is large enough to see meaningful performance differences
179+
- Other query bottlenecks aren't masking the improvement
180+
181+
### Debugging
182+
183+
Enable query logging to see the generated SQL:
184+
185+
```php
186+
DB::enableQueryLog();
187+
// Perform your search
188+
$queries = DB::getQueryLog();
189+
dd($queries);
190+
```
191+
192+
Look for `LEFT JOIN` statements in the search queries when optimization is enabled.
193+
194+
## Migration Guide
195+
196+
### Step 1: Test in Development
197+
```env
198+
# In .env
199+
RESTIFY_SEARCH_USE_JOINS=true
200+
```
201+
202+
### Step 2: Validate Query Behavior
203+
Run your test suite and verify search results remain consistent.
204+
205+
### Step 3: Performance Testing
206+
Measure query performance before and after enabling the optimization.
207+
208+
### Step 4: Production Rollout
209+
Enable in production after thorough testing in staging environments.
210+
211+
### Step 5: Monitor
212+
Watch for any performance regressions or unexpected query behavior.

src/Filters/SearchableFilter.php

Lines changed: 64 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -24,23 +24,17 @@ public function filter(RestifyRequest $request, $query, $value)
2424
return $query;
2525
}
2626

27-
// This approach could be rewritten using join.
28-
collect($this->belongsToField->getSearchables())->each(function (string $attribute) use ($query, $likeOperator, $value) {
29-
$query->orWhere(
30-
$this->belongsToField->getRelatedModel($this->repository)::select($attribute)
31-
->whereColumn(
32-
$this->belongsToField->getQualifiedKey($this->repository),
33-
$this->belongsToField->getRelatedKey($this->repository)
34-
)
35-
->take(1),
36-
$likeOperator,
37-
"%{$value}%"
38-
);
39-
});
27+
// Use JOIN optimization only if enabled in config and we have a BelongsTo relationship
28+
if (config('restify.search.use_joins', false)) {
29+
$this->applyJoinSearchConditions($query, $likeOperator, $value);
30+
} else {
31+
$this->applySubquerySearchConditions($query, $likeOperator, $value);
32+
}
4033

4134
return $query;
4235
}
4336

37+
// For direct field searches (non-relationship), always use the simple approach
4438
if (! config('restify.search.case_sensitive')) {
4539
$upper = strtoupper($value);
4640

@@ -63,4 +57,61 @@ public function computed(...$columns): self
6357

6458
return $this;
6559
}
60+
61+
protected function applyJoinSearchConditions($query, $likeOperator, $value)
62+
{
63+
$relatedModel = $this->belongsToField->getRelatedModel($this->repository);
64+
$relatedTable = $relatedModel->getTable();
65+
$parentTable = $this->repository->model()->getTable();
66+
67+
$localKey = $this->belongsToField->getQualifiedKey($this->repository);
68+
$foreignKeyName = $relatedModel->getKeyName();
69+
70+
// Use a consistent alias based on the relationship name to reuse joins
71+
$relationshipName = $this->belongsToField->getAttribute();
72+
$joinAlias = "{$relatedTable}_for_{$relationshipName}";
73+
74+
// Check if this exact join already exists
75+
$existingJoins = collect($query->getQuery()->joins ?? []);
76+
$joinExists = $existingJoins->contains(function ($join) use ($joinAlias, $relatedTable) {
77+
return $join->table === "{$relatedTable} as {$joinAlias}" ||
78+
str_contains($join->table, $joinAlias);
79+
});
80+
81+
if (!$joinExists) {
82+
$query->leftJoin("{$relatedTable} as {$joinAlias}", function ($join) use ($localKey, $joinAlias, $foreignKeyName) {
83+
$join->on($localKey, '=', "{$joinAlias}.{$foreignKeyName}");
84+
});
85+
}
86+
87+
// Apply search conditions for each searchable attribute
88+
collect($this->belongsToField->getSearchables())->each(function (string $attribute) use ($query, $likeOperator, $value, $joinAlias) {
89+
$qualifiedAttribute = "{$joinAlias}.{$attribute}";
90+
91+
if (!config('restify.search.case_sensitive')) {
92+
$upper = strtoupper($value);
93+
$query->orWhereRaw("UPPER({$qualifiedAttribute}) LIKE ?", ['%'.$upper.'%']);
94+
} else {
95+
$query->orWhere($qualifiedAttribute, $likeOperator, "%{$value}%");
96+
}
97+
});
98+
}
99+
100+
protected function applySubquerySearchConditions($query, $likeOperator, $value)
101+
{
102+
// Original implementation using subqueries for backward compatibility
103+
collect($this->belongsToField->getSearchables())->each(function (string $attribute) use ($query, $likeOperator, $value) {
104+
$query->orWhere(
105+
$this->belongsToField->getRelatedModel($this->repository)::select($attribute)
106+
->whereColumn(
107+
$this->belongsToField->getQualifiedKey($this->repository),
108+
$this->belongsToField->getRelatedKey($this->repository)
109+
)
110+
->take(1),
111+
$likeOperator,
112+
"%{$value}%"
113+
);
114+
});
115+
}
116+
66117
}

src/Services/Search/RepositorySearchService.php

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -115,9 +115,15 @@ public function prepareRelations(RestifyRequest $request, Builder|Relation $quer
115115
}
116116
})->all();
117117

118-
return $query->with(
119-
array_merge($filtered, ($this->repository)::withs())
120-
);
118+
$eagerRelations = array_merge($filtered, ($this->repository)::withs());
119+
120+
// Only exclude joined relationships if JOIN optimization is enabled
121+
if (config('restify.search.use_joins', false)) {
122+
$joinedRelations = $this->getJoinedRelationships($query);
123+
$eagerRelations = array_diff($eagerRelations, $joinedRelations);
124+
}
125+
126+
return $query->with($eagerRelations);
121127
}
122128

123129
public function prepareSearchFields(RestifyRequest $request, $query)
@@ -142,6 +148,7 @@ public function prepareSearchFields(RestifyRequest $request, $query)
142148
$query->orWhere($query->getModel()->getQualifiedKeyName(), $search);
143149
}
144150

151+
// Apply local field searches
145152
foreach ($this->repository::searchables() as $key => $column) {
146153
$filter = $column instanceof Filter
147154
? $column
@@ -156,14 +163,15 @@ public function prepareSearchFields(RestifyRequest $request, $query)
156163
);
157164

158165
$filter->filter($request, $query, $search);
159-
160-
$this->repository::collectRelated()
161-
->onlySearchable($request)
162-
->map(function (BelongsTo $field) {
163-
return SearchableFilter::make()->setRepository($this->repository)->usingBelongsTo($field);
164-
})
165-
->each(fn (SearchableFilter $filter) => $filter->filter($request, $query, $search));
166166
}
167+
168+
// Apply related field searches (moved outside the loop to avoid duplicate joins)
169+
$this->repository::collectRelated()
170+
->onlySearchable($request)
171+
->map(function (BelongsTo $field) {
172+
return SearchableFilter::make()->setRepository($this->repository)->usingBelongsTo($field);
173+
})
174+
->each(fn (SearchableFilter $filter) => $filter->filter($request, $query, $search));
167175
});
168176

169177
return $query;
@@ -242,6 +250,22 @@ protected function applyGroupBy(RestifyRequest $request, Repository $repository,
242250
return $query;
243251
}
244252

253+
protected function getJoinedRelationships($query): array
254+
{
255+
$joinedRelations = [];
256+
$joins = collect($query->getQuery()->joins ?? []);
257+
258+
// Extract relationship names from join aliases
259+
$joins->each(function ($join) use (&$joinedRelations) {
260+
if (str_contains($join->table, '_for_')) {
261+
$relationName = str($join->table)->after('_for_')->toString();
262+
$joinedRelations[] = $relationName;
263+
}
264+
});
265+
266+
return $joinedRelations;
267+
}
268+
245269
public static function make(): static
246270
{
247271
return new static;

0 commit comments

Comments
 (0)