Skip to content

Commit 2395dc2

Browse files
committed
fix: matchable
1 parent f506e67 commit 2395dc2

File tree

8 files changed

+705
-12
lines changed

8 files changed

+705
-12
lines changed

docs-v2/content/en/api/fields.md

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,208 @@ GET /api/restify/users?sort=-created_at # Descending
305305
GET /api/restify/users?sort=name,-created_at # Multiple fields
306306
```
307307

308+
## Matching
309+
310+
Fields can be made matchable, allowing API consumers to filter results using query parameters.
311+
312+
### Making Fields Matchable
313+
314+
Use the `matchable()` method or convenient aliases:
315+
316+
```php
317+
public function fields(RestifyRequest $request)
318+
{
319+
return [
320+
field('name')->matchableText(), // Text matching with LIKE
321+
field('email')->matchable('users.email'), // Custom column - users table email
322+
field('status')->matchableText(), // Text matching
323+
field('is_active')->matchableBool(), // Boolean matching
324+
field('age')->matchableInteger(), // Integer matching
325+
field('created_at')->matchableDatetime(), // Date matching
326+
field('price')->matchableBetween(), // Range matching
327+
field('tags')->matchableArray(), // Array/IN matching
328+
];
329+
}
330+
```
331+
332+
### Using Matchable Fields
333+
334+
Once fields are marked as matchable, API consumers can filter using query parameters:
335+
336+
```http
337+
GET /api/restify/posts?title=Laravel # Text matching
338+
GET /api/restify/posts?is_active=true # Boolean matching
339+
GET /api/restify/posts?user_id=5 # Integer matching
340+
GET /api/restify/posts?created_at=2023-12-01 # Date matching
341+
GET /api/restify/posts?price=100,500 # Range matching
342+
GET /api/restify/posts?tags=php,laravel # Array matching
343+
344+
# Negation (prefix with -)
345+
GET /api/restify/posts?-status=draft # Exclude drafts
346+
GET /api/restify/posts?-is_active=true # Inactive posts
347+
348+
# Null checks
349+
GET /api/restify/posts?description=null # Posts with no description
350+
```
351+
352+
### Match Types Reference
353+
354+
| Alias | Type | Example Usage | Query Behavior |
355+
|-------|------|---------------|----------------|
356+
| `matchableText()` | text | `?name=john` | `WHERE name LIKE '%john%'` |
357+
| `matchableBool()` | boolean | `?is_active=true` | `WHERE is_active = 1` |
358+
| `matchableInteger()` | integer | `?user_id=5` | `WHERE user_id = 5` |
359+
| `matchableDatetime()` | datetime | `?created_at=2023-12-01` | `WHERE DATE(created_at) = '2023-12-01'` |
360+
| `matchableBetween()` | between | `?price=100,500` | `WHERE price BETWEEN 100 AND 500` |
361+
| `matchableArray()` | array | `?tags=php,laravel` | `WHERE tags IN ('php', 'laravel')` |
362+
363+
### Advanced Matchable Configuration
364+
365+
The `matchable()` method is flexible and accepts multiple types of arguments for advanced filtering scenarios:
366+
367+
#### Basic Usage (No Arguments)
368+
369+
When called without arguments, `matchable()` enables text-based matching using the field's attribute name:
370+
371+
```php
372+
field('title')->matchable(), // Enables text matching on 'title' column
373+
```
374+
375+
#### Custom Column
376+
377+
Specify a different database column for matching:
378+
379+
```php
380+
field('display_name')->matchable('users.name'), // Match against 'users.name' column
381+
```
382+
383+
#### Custom Match Type
384+
385+
Specify both column and match type:
386+
387+
```php
388+
field('status')->matchable('posts.status', 'text'), // Custom column with text matching
389+
field('priority')->matchable('priority', 'integer'), // Integer matching
390+
```
391+
392+
#### Closure-based Matching
393+
394+
For complex filtering logic, pass a closure that receives the request, query builder, and value:
395+
396+
```php
397+
field('title')->matchable(function ($request, $query, $value) {
398+
// Custom search logic - case insensitive partial matching
399+
$query->where('title', 'like', "%{$value}%");
400+
}),
401+
402+
field('content')->matchable(function ($request, $query, $value) {
403+
// Full-text search across multiple columns
404+
$query->whereRaw("MATCH(title, content) AGAINST(? IN BOOLEAN MODE)", [$value]);
405+
}),
406+
407+
field('location')->matchable(function ($request, $query, $value) {
408+
// Complex geographical search
409+
[$lat, $lng, $radius] = explode(',', $value);
410+
$query->whereRaw(
411+
'ST_Distance_Sphere(POINT(longitude, latitude), POINT(?, ?)) <= ?',
412+
[$lng, $lat, $radius * 1000]
413+
);
414+
}),
415+
```
416+
417+
#### Custom MatchFilter Classes
418+
419+
For reusable complex filtering logic, create custom MatchFilter classes:
420+
421+
```php
422+
use Binaryk\LaravelRestify\Filters\MatchFilter;
423+
use Binaryk\LaravelRestify\Http\Requests\RestifyRequest;
424+
use Illuminate\Database\Eloquent\Builder;
425+
use Illuminate\Database\Eloquent\Relations\Relation;
426+
427+
class CustomTitleFilter extends MatchFilter
428+
{
429+
public function __construct()
430+
{
431+
parent::__construct();
432+
$this->setColumn('title'); // Set the column to filter on
433+
}
434+
435+
public function filter(RestifyRequest $request, Builder|Relation $query, $value)
436+
{
437+
// Custom filtering logic: search for titles that start with the given value
438+
$query->where('title', 'like', "{$value}%");
439+
440+
return $query;
441+
}
442+
}
443+
```
444+
445+
Then use the custom filter in your field definition:
446+
447+
```php
448+
field('title')->matchable(new CustomTitleFilter()),
449+
```
450+
451+
#### Invokable Classes
452+
453+
You can also use invokable classes for cleaner code organization:
454+
455+
```php
456+
class SearchTitleFilter
457+
{
458+
public function __invoke($request, $query, $value)
459+
{
460+
$query->where('title', 'like', "%{$value}%")
461+
->orWhere('description', 'like', "%{$value}%");
462+
}
463+
}
464+
465+
// Usage
466+
field('search')->matchable(new SearchTitleFilter()),
467+
```
468+
469+
#### Practical Examples
470+
471+
**E-commerce Product Search:**
472+
```php
473+
field('search')->matchable(function ($request, $query, $value) {
474+
$query->where(function ($q) use ($value) {
475+
$q->where('name', 'like', "%{$value}%")
476+
->orWhere('description', 'like', "%{$value}%")
477+
->orWhere('sku', 'like', "%{$value}%");
478+
});
479+
}),
480+
```
481+
482+
**Date Range Filtering:**
483+
```php
484+
field('date_range')->matchable(function ($request, $query, $value) {
485+
[$start, $end] = explode(',', $value);
486+
$query->whereBetween('created_at', [$start, $end]);
487+
}),
488+
```
489+
490+
**Tag-based Filtering:**
491+
```php
492+
field('tags')->matchable(function ($request, $query, $value) {
493+
$tags = explode(',', $value);
494+
$query->whereHas('tags', function ($q) use ($tags) {
495+
$q->whereIn('slug', $tags);
496+
});
497+
}),
498+
```
499+
500+
**Relationship Filtering:**
501+
```php
502+
field('author')->matchable(function ($request, $query, $value) {
503+
$query->whereHas('author', function ($q) use ($value) {
504+
$q->where('name', 'like', "%{$value}%")
505+
->orWhere('email', 'like', "%{$value}%");
506+
});
507+
}),
508+
```
509+
308510
## Validation
309511

310512
There is a golden rule that says - catch the exception as soon as possible on its request way.

src/Fields/Concerns/CanMatch.php

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
<?php
2+
3+
namespace Binaryk\LaravelRestify\Fields\Concerns;
4+
5+
use Binaryk\LaravelRestify\Contracts\RestifySearchable;
6+
use Binaryk\LaravelRestify\Filters\MatchFilter;
7+
use Binaryk\LaravelRestify\Http\Requests\RestifyRequest;
8+
9+
trait CanMatch
10+
{
11+
protected mixed $matchableColumn = null;
12+
13+
protected ?string $matchableType = null;
14+
15+
public function matchable(mixed $column = null, ?string $type = null): self
16+
{
17+
if ($column === false) {
18+
$this->matchableColumn = null;
19+
$this->matchableType = null;
20+
21+
return $this;
22+
}
23+
24+
if (is_callable($column)) {
25+
$this->matchableColumn = $column;
26+
$this->matchableType = 'custom';
27+
28+
return $this;
29+
}
30+
31+
if ($column instanceof MatchFilter) {
32+
$this->matchableColumn = $column;
33+
$this->matchableType = 'custom';
34+
35+
return $this;
36+
}
37+
38+
$this->matchableColumn = $column ?? $this->getAttribute();
39+
$this->matchableType = $type ?? $this->guessMatchType();
40+
41+
return $this;
42+
}
43+
44+
public function matchableCallback(callable $callback): self
45+
{
46+
return $this->matchable($callback, 'custom');
47+
}
48+
49+
public function matchableText(?string $column = null): self
50+
{
51+
return $this->matchable($column, RestifySearchable::MATCH_TEXT);
52+
}
53+
54+
public function matchableBool(?string $column = null): self
55+
{
56+
return $this->matchable($column, RestifySearchable::MATCH_BOOL);
57+
}
58+
59+
public function matchableBoolean(?string $column = null): self
60+
{
61+
return $this->matchableBool($column);
62+
}
63+
64+
public function matchableInteger(?string $column = null): self
65+
{
66+
return $this->matchable($column, RestifySearchable::MATCH_INTEGER);
67+
}
68+
69+
public function matchableInt(?string $column = null): self
70+
{
71+
return $this->matchableInteger($column);
72+
}
73+
74+
public function matchableDatetime(?string $column = null): self
75+
{
76+
return $this->matchable($column, RestifySearchable::MATCH_DATETIME);
77+
}
78+
79+
public function matchableDate(?string $column = null): self
80+
{
81+
return $this->matchableDatetime($column);
82+
}
83+
84+
public function matchableBetween(?string $column = null): self
85+
{
86+
return $this->matchable($column, RestifySearchable::MATCH_BETWEEN);
87+
}
88+
89+
public function matchableArray(?string $column = null): self
90+
{
91+
return $this->matchable($column, RestifySearchable::MATCH_ARRAY);
92+
}
93+
94+
public function isMatchable(RestifyRequest $request = null): bool
95+
{
96+
if (is_callable($this->matchableColumn)) {
97+
return true;
98+
}
99+
100+
return ! is_null($this->matchableColumn);
101+
}
102+
103+
public function getMatchColumn(RestifyRequest $request = null): mixed
104+
{
105+
if (! $this->isMatchable($request)) {
106+
return null;
107+
}
108+
109+
return $this->matchableColumn;
110+
}
111+
112+
public function getMatchType(RestifyRequest $request = null): ?string
113+
{
114+
if (! $this->isMatchable($request)) {
115+
return null;
116+
}
117+
118+
return $this->matchableType;
119+
}
120+
121+
protected function guessMatchType(): string
122+
{
123+
// Use field type detection from Field class if available
124+
if (method_exists($this, 'guessFieldType')) {
125+
$fieldType = $this->guessFieldType();
126+
127+
return match ($fieldType) {
128+
'boolean' => RestifySearchable::MATCH_BOOL,
129+
'number' => RestifySearchable::MATCH_INTEGER,
130+
'array' => RestifySearchable::MATCH_ARRAY,
131+
default => RestifySearchable::MATCH_TEXT,
132+
};
133+
}
134+
135+
// Fallback to attribute name patterns
136+
$attribute = $this->getAttribute();
137+
138+
if (! is_string($attribute)) {
139+
return RestifySearchable::MATCH_TEXT;
140+
}
141+
142+
$attribute = strtolower($attribute);
143+
144+
// Boolean patterns
145+
if (preg_match('/^(is_|has_|can_|should_|will_|was_|were_)/', $attribute) ||
146+
in_array($attribute,
147+
['active', 'enabled', 'disabled', 'verified', 'published', 'featured', 'public', 'private'])) {
148+
return RestifySearchable::MATCH_BOOL;
149+
}
150+
151+
// Number patterns
152+
if (preg_match('/_(id|count|number|amount|price|cost|total|sum|quantity|qty)$/', $attribute) ||
153+
in_array($attribute,
154+
['id', 'age', 'year', 'month', 'day', 'hour', 'minute', 'second', 'weight', 'height', 'size'])) {
155+
return RestifySearchable::MATCH_INTEGER;
156+
}
157+
158+
// Date patterns
159+
if (preg_match('/_(at|date|time)$/', $attribute) ||
160+
in_array($attribute,
161+
['created_at', 'updated_at', 'deleted_at', 'published_at', 'birthday', 'date_of_birth'])) {
162+
return RestifySearchable::MATCH_DATETIME;
163+
}
164+
165+
// Array patterns (JSON fields)
166+
if (preg_match('/_(json|data|metadata|config|settings|options|tags)$/', $attribute)) {
167+
return RestifySearchable::MATCH_ARRAY;
168+
}
169+
170+
// Default to text matching
171+
return RestifySearchable::MATCH_TEXT;
172+
}
173+
}

src/Fields/Contracts/Matchable.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
namespace Binaryk\LaravelRestify\Fields\Contracts;
4+
5+
use Binaryk\LaravelRestify\Http\Requests\RestifyRequest;
6+
7+
interface Matchable
8+
{
9+
public function matchable(mixed $column = null, ?string $type = null): self;
10+
11+
public function isMatchable(RestifyRequest $request = null): bool;
12+
13+
public function getMatchColumn(RestifyRequest $request = null): mixed;
14+
15+
public function getMatchType(RestifyRequest $request = null): ?string;
16+
}

0 commit comments

Comments
 (0)