Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions config/restify.php
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,29 @@
'use_joins_for_belongs_to' => env('RESTIFY_USE_JOINS_FOR_BELONGS_TO', false),
],

/*
|--------------------------------------------------------------------------
| Restify Sort
|--------------------------------------------------------------------------
*/
'sort' => [
/*
|--------------------------------------------------------------------------
| Use JOINs for BelongsTo/HasOne Relationship Sorts
|--------------------------------------------------------------------------
|
| When enabled, sortables that target a related column will emit a LEFT JOIN
| + ORDER BY <related_table>.<column> instead of an ORDER BY (SELECT ... LIMIT 1)
| correlated subquery. Optimizer-friendly; mirrors restify.search.use_joins_for_belongs_to.
|
| A SortableFilter can override this globally with ->useJoin() or ->useSubquery().
|
| Default: false (legacy subquery behavior preserved for backward compatibility).
|
*/
'use_joins_for_belongs_to' => env('RESTIFY_SORT_USE_JOINS_FOR_BELONGS_TO', false),
],

'repositories' => [

/*
Expand Down
91 changes: 84 additions & 7 deletions docs-v3/content/docs/4.search/1.basic-filters.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,17 +121,94 @@ GET: /api/restify/users?search="John Doe"

## Case-sensitive search

By default, Restify search is case-sensitive. You can change this behavior by changing the configuration:
By default, Restify search is case-sensitive. You can change this behavior globally:

```php [restify.php]
'search' => [
/*
| Specify either the search should be case-sensitive or not.
*/
'case_sensitive' => false,
],
'search' => [
/*
| Specify either the search should be case-sensitive or not.
*/
'case_sensitive' => false,
],
```

When `case_sensitive` is `false`, every searchable column is wrapped in `UPPER()`:

```sql
WHERE UPPER(posts.title) LIKE UPPER('%foo%')
```

This matches case-insensitively at the cost of bypassing any column index. For columns where you know the stored case (numeric, FK ids, codes stored uppercase, status enums stored lowercase), you can opt into index-friendly modes per column.

### Per-field case modes

`SearchableFilter` exposes chainable methods to control how the column expression and the search value are transformed for the `LIKE` comparison. Mix them in the `searchables()` array:

```php
use Binaryk\LaravelRestify\Filters\SearchableFilter;

public static function searchables(): array
{
return [
SearchableFilter::make('gross_amount')->caseRaw(), // numeric
SearchableFilter::make('vendor_code')->upperValue(), // stored uppercase
SearchableFilter::make('status')->lowerValue(), // stored lowercase
SearchableFilter::make('description')->upperBoth(), // human text
];
}
```

Available modes:

| Method | Column | Value | Use when |
|---|---|---|---|
| `->caseRaw()` | raw | raw | numerics, FK ids, dates, blind-indexed |
| `->upperValue()` | raw | UPPER | column always stored UPPER (e.g. vendor codes) |
| `->lowerValue()` | raw | LOWER | column always stored lowercase (e.g. status enums) |
| `->upperBoth()` | UPPER | UPPER | legacy case-insensitive (column case unknown) |
| `->lowerBoth()` | LOWER | LOWER | mirror of `upperBoth` |

`*Value` modes leave the column expression untouched, so any index on that column is still usable. `*Both` modes wrap both sides — case-insensitive but kills indexes.

<alert type="warning">

`upperValue` and `lowerValue` are silent contracts: they assume the column is **always** stored in that case. Rows stored in mixed/opposite case will not match the search.

</alert>

### Custom value transformer

For normalization beyond simple case (trim, unicode fold, digit-strip, etc.), pass any callable to `transform()`:

```php
SearchableFilter::make('email')->transform(
fn (string $v): string => trim(strtolower($v))
),
SearchableFilter::make('phone')->transform(
fn (string $v): string => preg_replace('/\D/', '', $v)
),
```

The column stays raw; the callable controls the value before the `LIKE` comparison.

### Per-field overrides on BelongsTo searchables

`BelongsTo::->searchable([...])` accepts a mix of plain strings and `SearchableFilter` instances, so per-column case modes work for joined searchables too:

```php
'vendor' => BelongsTo::make('vendor', VendorRepository::class)->searchable([
SearchableFilter::make('vendors.code')->upperValue(), // index-friendly
'vendors.name', // default behavior
]),
```

### Resolution order

Per searchable column, the resolution is:

1. Instance method (`caseRaw`, `upperValue`, `transform`, etc.) on the `SearchableFilter` — wins.
2. Otherwise, derive from `restify.search.case_sensitive` — `true` → raw, `false` → UPPER both.

### Custom search filter

The search could be customized by creating a class that extends the `\Binaryk\LaravelRestify\Filters\SearchableFilter`:
Expand Down
59 changes: 59 additions & 0 deletions docs-v3/content/docs/4.search/3.sorting.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,65 @@ As you may notice we have typed twice the `users.name` (on the array key, and as

</alert>

## JOIN strategy for sort-by-relation

By default, sorting by a `BelongsTo` or `HasOne` relation column emits a correlated subquery in the `ORDER BY` clause:

```sql
ORDER BY (SELECT vendors.code FROM vendors WHERE vendors.id = invoices.vendor_id LIMIT 1) ASC
```

This evaluates per row and prevents the database optimizer from using indexes on the related column. Restify can emit a `LEFT JOIN` instead, which is index-friendly:

```sql
LEFT JOIN vendors ON invoices.vendor_id = vendors.id
ORDER BY vendors.code ASC
```

### Enable globally

Flip the config flag (default `false`) to switch every sort-by-relation to `LEFT JOIN`:

```php [config/restify.php]
'sort' => [
'use_joins_for_belongs_to' => env('RESTIFY_SORT_USE_JOINS_FOR_BELONGS_TO', false),
],
```

### Override per filter

Force `LEFT JOIN` (or the legacy subquery) for a single sortable, regardless of the config flag:

```php [PostRepository.php]
use Binaryk\LaravelRestify\Fields\BelongsTo;
use Binaryk\LaravelRestify\Filters\SortableFilter;

public static function sorts(): array
{
return [
'users.name' => SortableFilter::make()
->setColumn('users.name')
->usingRelation(BelongsTo::make('user', UserRepository::class))
->useJoin(), // force LEFT JOIN
// ->useSubquery() // force legacy subquery
];
}
```

Resolution order: per-filter (`useJoin` / `useSubquery`) wins over the config flag, which wins over the legacy subquery default.

<alert type="info">

When search has already left-joined the same related table (because `BelongsTo->searchable([...])` is configured and `restify.search.use_joins_for_belongs_to=true`), the sort path detects the existing join and reuses it — exactly one join per related table.

</alert>

<alert type="warning">

`LEFT JOIN` is supported on `BelongsTo` and `HasOne` only — the two relation types `usingRelation()` accepts. `HasMany`, `MorphTo`, and `BelongsToMany` always use the subquery path; a `LEFT JOIN` would multiply rows and corrupt the result set.

</alert>

## Sort using closure

If you have a quick sort method, you can use a closure to sort your data:
Expand Down
9 changes: 9 additions & 0 deletions docs-v3/content/docs/7.performance/1.performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,15 @@ Index meta are policy information related to what actions are allowed on a resou

This will give your application a boost, especially when loading a large amount of resources or relations.

## Index-friendly search and sort

Restify ships with two opt-in optimizations that let database indexes do their work:

- **Sort by relation** can emit a `LEFT JOIN` instead of a per-row correlated subquery. See [JOIN strategy for sort-by-relation](/docs/search/sorting#join-strategy-for-sort-by-relation).
- **Searchable case modes** let each column declare whether to wrap the column in `UPPER()` / `LOWER()`, transform only the search value, or stay strictly raw. See [Per-field case modes](/docs/search/basic-filters#per-field-case-modes).

Both are off by default and rolled out per-column or per-config — strict backward compatibility.

## Repository Index Caching

Laravel Restify provides powerful caching for repository index requests to dramatically improve performance for expensive queries with filters, searches, sorts, and pagination. This feature can reduce response times by orders of magnitude for complex API endpoints.
Expand Down
42 changes: 36 additions & 6 deletions src/Fields/BelongsTo.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@

use Binaryk\LaravelRestify\Fields\Concerns\Attachable;
use Binaryk\LaravelRestify\Fields\Contracts\Sortable;
use Binaryk\LaravelRestify\Filters\SearchableFilter;
use Binaryk\LaravelRestify\Http\Requests\RestifyRequest;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
use InvalidArgumentException;

class BelongsTo extends EagerField implements Sortable
{
Expand Down Expand Up @@ -49,12 +51,14 @@ public function fillAttribute(RestifyRequest $request, $model, ?int $bulkRow = n
*/
public function searchable(...$attributes): self
{
// Handle case where a single array is passed (legacy behavior)
// Handle case where a single array is passed (legacy behavior).
// flatten(1) preserves SearchableFilter instances inside the array; deeper flattening
// would dissolve them into their internal arrays.
if (count($attributes) === 1 && is_array($attributes[0])) {
$this->searchablesAttributes = collect($attributes[0])->flatten()->all();
$this->searchablesAttributes = collect($attributes[0])->flatten(1)->all();
// Also call parent with the first attribute for consistency
if (! empty($this->searchablesAttributes)) {
parent::searchable($this->searchablesAttributes[0]);
parent::searchable($this->extractColumn($this->searchablesAttributes[0]));
}

return $this;
Expand All @@ -69,17 +73,25 @@ public function searchable(...$attributes): self
return $this;
}

if (count($attributes) === 1 && $attributes[0] instanceof SearchableFilter) {
$this->searchablesAttributes = [$attributes[0]];

parent::searchable($this->extractColumn($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();
if (count($attributes) > 1 && collect($attributes)->every(fn ($attr) => is_string($attr) || $attr instanceof SearchableFilter)) {
$this->searchablesAttributes = collect($attributes)->flatten(1)->all();
// Also call parent to maintain consistency with CanSearch trait
parent::searchable($attributes[0]);
parent::searchable($this->extractColumn($this->searchablesAttributes[0]));

return $this;
}
Expand All @@ -90,6 +102,24 @@ public function searchable(...$attributes): self
return $this;
}

private function extractColumn(string|SearchableFilter $entry): string
{
if (is_string($entry)) {
return $entry;
}

$column = $entry->column();

if ($column === null || $column === '') {
throw new InvalidArgumentException(
'SearchableFilter passed to BelongsTo::searchable() has no column. '
.'Pass it as SearchableFilter::make(\'column\') or call setColumn() before passing.'
);
}

return $column;
}

/**
* Check if this BelongsTo field is searchable (either via attributes or parent CanSearch)
*/
Expand Down
37 changes: 37 additions & 0 deletions src/Filters/Concerns/AppliesRelationJoin.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

declare(strict_types=1);

namespace Binaryk\LaravelRestify\Filters\Concerns;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\Relation;

trait AppliesRelationJoin
{
protected function ensureLeftJoin(
Builder|Relation $query,
string $relatedTable,
string $localQualifiedKey,
string $relatedQualifiedKey,
string $mainTable,
): void {
// Add LEFT JOIN only if a left join on the same table hasn't been added already.
// Match join type so an upstream INNER JOIN doesn't suppress our LEFT JOIN — the two
// have different semantics (INNER drops rows with no match; LEFT keeps them).
$exists = collect($query->toBase()->joins ?? [])
->contains(static fn ($join): bool => $join->table === $relatedTable && $join->type === 'left');

if ($exists) {
return;
}

// 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)) {
$query->select([$mainTable.'.*']);
}

$query->leftJoin($relatedTable, $localQualifiedKey, '=', $relatedQualifiedKey);
}
}
Loading