Skip to content

Commit f506e67

Browse files
committed
feat: adding fluent api for sortable
1 parent 572f25a commit f506e67

File tree

20 files changed

+512
-128
lines changed

20 files changed

+512
-128
lines changed

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

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,60 @@ $field = field('full_name', fn() => "$this->first_name $this->last_name");
251251
$isComputed = $field->computed(); // Returns: true
252252
```
253253

254+
## Sorting
255+
256+
Fields can be made sortable, allowing API consumers to order results by field values.
257+
258+
### Making Fields Sortable
259+
260+
To make a field sortable, chain the `sortable()` method:
261+
262+
```php
263+
public function fields(RestifyRequest $request)
264+
{
265+
return [
266+
field('name')->sortable(),
267+
field('email')->sortable(),
268+
field('created_at')->sortable(),
269+
field('is_active')->sortable(),
270+
];
271+
}
272+
```
273+
274+
### Sortable Column Configuration
275+
276+
By default, the field's attribute name is used as the sortable column. You can specify a different column:
277+
278+
```php
279+
field('full_name')->sortable('name'), // Use 'name' column for 'full_name' field
280+
```
281+
282+
### Disabling Sorting
283+
284+
You can disable sorting for a field that was previously made sortable:
285+
286+
```php
287+
field('sensitive_data')->sortable(false),
288+
```
289+
290+
### Conditional Sorting
291+
292+
Make fields conditionally sortable based on request context:
293+
294+
```php
295+
field('internal_score')->sortable(fn($request) => $request->user()->isAdmin()),
296+
```
297+
298+
### Using Sortable Fields
299+
300+
Once fields are marked as sortable, API consumers can use them in sort requests:
301+
302+
```http
303+
GET /api/restify/users?sort=name
304+
GET /api/restify/users?sort=-created_at # Descending
305+
GET /api/restify/users?sort=name,-created_at # Multiple fields
306+
```
307+
254308
## Validation
255309

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

docs-v2/content/en/index.md

Lines changed: 1 addition & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -30,46 +30,12 @@ This documentation is for the latest version of Laravel Restify. Please ensure y
3030
'Powerful Search',
3131
'JSON:API consistency',
3232
'GraphQL Schema Generation',
33-
'MCP (Model Context Protocol) Integration',
33+
'MCP (Model Context Protocol) Server Generation',
3434
'Customizable',
3535
'Laravel Compatible Authorization'
3636
]">
3737
</list>
3838

39-
## 🤖 AI-Powered Development with MCP
40-
41-
Transform your existing Laravel Restify API into an **MCP-enabled powerhouse in minutes**! Laravel Restify's Model Context Protocol integration allows AI agents to interact directly with your API resources through structured tool interfaces.
42-
43-
<alert type="success">
44-
45-
**🔥 Transform Your API in Minutes!** Add one trait to your Repository class and register one route - that's it! Your entire API becomes AI-agent accessible with full security and authorization intact.
46-
47-
</alert>
48-
49-
**Quick Setup (2 steps):**
50-
51-
1. **Add the trait to your Repository:**
52-
```php
53-
use Binaryk\LaravelRestify\MCP\Concerns\HasMcpTools;
54-
55-
class PostRepository extends Repository
56-
{
57-
use HasMcpTools; // ✨ This enables MCP for this resource
58-
}
59-
```
60-
61-
2. **Register the MCP server in `routes/ai.php`:**
62-
```php
63-
use Laravel\Mcp\Facades\Mcp;
64-
use Binaryk\LaravelRestify\MCP\RestifyServer;
65-
66-
Mcp::web('restify', RestifyServer::class)->middleware(['auth:sanctum']);
67-
```
68-
69-
**That's it!** Your API is now AI-agent ready with automatic tool generation for CRUD operations, actions, and getters.
70-
71-
[Learn more about MCP Integration →](/mcp)
72-
7339
## Accelerate Your Development
7440

7541
Want to skip the boilerplate and launch faster? Our Restify Templates come with everything you need:

src/Commands/GenerateRepositoriesCommand.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,7 @@ protected function analyzeModelFields(string $className): array
253253
return $fields;
254254
}
255255

256-
protected function mapColumnToRestifyField(string $column, string $columnType): string
256+
protected function mapColumnToRestifyField(string $column, string $columnType): ?string
257257
{
258258
// Skip ID field as it's handled automatically
259259
if ($column === 'id') {
@@ -288,6 +288,7 @@ protected function mapColumnToRestifyField(string $column, string $columnType):
288288

289289
case 'integer':
290290
case 'bigint':
291+
case 'double':
291292
case 'smallint':
292293
$field .= '->number()';
293294
break;
@@ -308,7 +309,6 @@ protected function mapColumnToRestifyField(string $column, string $columnType):
308309

309310
case 'decimal':
310311
case 'float':
311-
case 'double':
312312
$field .= '->number()';
313313
break;
314314

src/Commands/PolicyCommand.php

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ protected function buildClass($name)
2525

2626
$class = $this->replaceQualifiedModel($class);
2727

28+
$class = $this->replaceUserImport($class);
29+
2830
return $class;
2931
}
3032

@@ -45,6 +47,21 @@ protected function replaceQualifiedModel($stub)
4547
return str_replace('{{ modelQualified }}', $this->guessQualifiedModel(), $stub);
4648
}
4749

50+
protected function replaceUserImport($stub)
51+
{
52+
$qualifiedModel = $this->guessQualifiedModel();
53+
$userQualified = $this->rootNamespace().'Models\User';
54+
55+
// If the model being generated is the User model, don't duplicate the import
56+
if ($qualifiedModel === $userQualified) {
57+
// Remove the entire user import line since it would be duplicate
58+
$stub = preg_replace('/use\s+\{\{\s*userQualified\s*\}\};\s*\n/', '', $stub);
59+
return $stub;
60+
}
61+
62+
return str_replace('{{ userQualified }}', $userQualified, $stub);
63+
}
64+
4865
protected function guessQualifiedModel(): string
4966
{
5067
$model = Str::singular(class_basename(Str::beforeLast($this->getNameInput(), 'Policy')));

src/Commands/RepositoryCommand.php

Lines changed: 43 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ public function handle()
4242

4343
return false;
4444
}
45+
46+
// Set force option to true since user confirmed override
47+
$this->input->setOption('force', true);
4548
}
4649

4750
if (parent::handle() === false && ! $this->option('force')) {
@@ -100,7 +103,8 @@ protected function buildClass($name)
100103
$stub = parent::buildClass($name);
101104

102105
// Replace DummyRootNamespace placeholder
103-
$stub = str_replace('DummyRootNamespace', $this->rootNamespace(), $stub);
106+
$rootNamespace = rtrim($this->rootNamespace(), '\\');
107+
$stub = str_replace('DummyRootNamespace', $rootNamespace, $stub);
104108

105109
$stub = $this->replaceModel($stub, $this->guessBaseModelClass());
106110

@@ -115,6 +119,9 @@ protected function buildClass($name)
115119
$stub = str_replace('{{ relationshipImports }}', '', $stub);
116120
}
117121

122+
// Clean up any double backslashes in the final stub
123+
$stub = str_replace('\\\\', '\\', $stub);
124+
118125
return $stub;
119126
}
120127

@@ -142,7 +149,7 @@ protected function guessQualifiedModelName()
142149
}
143150

144151
$model = Str::singular(class_basename(Str::before($this->getNameInput(), 'Repository')));
145-
$defaultModelClass = str_replace('/', '\\', $this->rootNamespace().'/Models//'.$model);
152+
$defaultModelClass = str_replace('/', '\\', $this->rootNamespace().'/Models/'.$model);
146153

147154
// If default model exists, use it
148155
if (class_exists($defaultModelClass)) {
@@ -251,12 +258,22 @@ protected function getPath($name)
251258
if ($existingRepositoryPath && $existingRepositoryPath['pattern']) {
252259
// Apply the discovered pattern
253260
$modelBaseName = Str::before(class_basename($name), 'Repository');
254-
$namespacedName = $existingRepositoryPath['namespace'].'\\'.$this->applyPathPattern(
255-
$modelBaseName,
256-
$existingRepositoryPath['pattern']
257-
).'\\'.$name;
258-
259-
return $this->laravel['path'].'/'.str_replace('\\', '/', str_replace($this->rootNamespace().'\\', '', $namespacedName)).'.php';
261+
$patternPath = $this->applyPathPattern($modelBaseName, $existingRepositoryPath['pattern']);
262+
263+
// Build the namespace path, avoiding duplication
264+
$namespaceParts = [];
265+
$baseNamespace = str_replace($this->rootNamespace().'\\', '', $existingRepositoryPath['namespace']);
266+
if ($baseNamespace) {
267+
$namespaceParts[] = $baseNamespace;
268+
}
269+
270+
if ($patternPath && !empty($patternPath)) {
271+
$namespaceParts[] = str_replace('\\', '/', $patternPath);
272+
}
273+
274+
$namespaceParts[] = class_basename($name);
275+
276+
return $this->laravel['path'].'/'.implode('/', $namespaceParts).'.php';
260277
}
261278

262279
return parent::getPath($name);
@@ -268,11 +285,27 @@ protected function getDefaultNamespace($rootNamespace)
268285
$existingRepositoryPath = $this->findExistingRepositoryPath();
269286

270287
if ($existingRepositoryPath) {
271-
return $existingRepositoryPath['namespace'];
288+
$namespace = $existingRepositoryPath['namespace'];
289+
290+
// Apply pattern for the current model if needed
291+
if ($existingRepositoryPath['pattern'] && $existingRepositoryPath['pattern'] !== 'flat') {
292+
$modelBaseName = Str::before(class_basename($this->getNameInput()), 'Repository');
293+
$patternPath = $this->applyPathPattern($modelBaseName, $existingRepositoryPath['pattern']);
294+
295+
if ($patternPath && !empty($patternPath)) {
296+
// Only add pattern path if it doesn't already exist in namespace
297+
$patternPathNormalized = str_replace('/', '\\', $patternPath);
298+
if (!Str::endsWith($namespace, $patternPathNormalized)) {
299+
$namespace .= '\\' . $patternPathNormalized;
300+
}
301+
}
302+
}
303+
304+
return $namespace;
272305
}
273306

274307
// Fallback to default
275-
return $rootNamespace.'\Restify';
308+
return rtrim($rootNamespace, '\\').'\\Restify';
276309
}
277310

278311
protected function replaceFields($stub)

src/Commands/SetupCommand.php

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,11 @@ public function handle()
3838
$this->registerRestifyServiceProvider();
3939

4040
$this->comment('Generating User Repository...');
41-
$this->callSilent('restify:repository', ['name' => 'User']);
42-
copy(__DIR__.'/stubs/user-repository.stub', app_path('Restify/UserRepository.php'));
41+
$this->call(RepositoryCommand::class, ['name' => 'UserRepository']);
4342

4443
if (! file_exists(app_path('Policies/UserPolicy.php'))) {
4544
app(Filesystem::class)->ensureDirectoryExists(app_path('Policies'));
46-
copy(__DIR__.'/stubs/user-policy.stub', app_path('Policies/UserPolicy.php'));
45+
$this->call(PolicyCommand::class, ['name' => 'UserPolicy']);
4746
}
4847

4948
$this->setAppNamespace();

src/Commands/stubs/policy.stub

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
namespace DummyNamespace;
44

5-
use App\Models\User;
5+
use {{ userQualified }};
66
use Illuminate\Auth\Access\HandlesAuthorization;
77
use {{ modelQualified }};
88

src/Eager/RelatedCollection.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,16 +59,16 @@ public function forBelongsToRelations(RestifyRequest $request): self
5959
})->filter(fn (EagerField $field) => $field->authorize($request));
6060
}
6161

62-
public function mapIntoSortable(): self
62+
public function mapIntoSortable(RestifyRequest $request): self
6363
{
6464
return $this
6565
->filter(fn ($key) => $key instanceof Sortable)
66-
->filter(fn (Sortable $field) => $field->isSortable())
67-
->map(function (Sortable $field) {
66+
->filter(fn (Sortable $field) => $field->isSortable($request))
67+
->map(function (Sortable $field) use ($request) {
6868
$filter = SortableFilter::make();
6969

7070
if ($field instanceof BelongsTo || $field instanceof HasOne) {
71-
return $filter->usingRelation($field)->setColumn($field->qualifySortable());
71+
return $filter->usingRelation($field)->setColumn($field->qualifySortable($request));
7272
}
7373

7474
return null;

src/Fields/BelongsTo.php

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
namespace Binaryk\LaravelRestify\Fields;
44

55
use Binaryk\LaravelRestify\Fields\Concerns\Attachable;
6-
use Binaryk\LaravelRestify\Fields\Concerns\CanSort;
76
use Binaryk\LaravelRestify\Fields\Contracts\Sortable;
87
use Binaryk\LaravelRestify\Http\Requests\RestifyRequest;
98
use Illuminate\Database\Eloquent\Model;
@@ -12,7 +11,6 @@
1211
class BelongsTo extends EagerField implements Sortable
1312
{
1413
use Attachable;
15-
use CanSort;
1614

1715
public ?array $searchablesAttributes = null;
1816

src/Fields/Concerns/CanSort.php

Lines changed: 27 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,40 +2,46 @@
22

33
namespace Binaryk\LaravelRestify\Fields\Concerns;
44

5-
use Illuminate\Support\Str;
5+
use Binaryk\LaravelRestify\Http\Requests\RestifyRequest;
66

77
trait CanSort
88
{
9-
protected ?string $sortableColumn = null;
9+
protected mixed $sortableColumn = null;
1010

11-
public function sortable(string $column): self
11+
public function sortable(mixed $column = null): self
1212
{
13-
$this->sortableColumn = $column;
13+
if ($column === false) {
14+
$this->sortableColumn = null;
1415

15-
return $this;
16-
}
16+
return $this;
17+
}
1718

18-
public function isSortable(): bool
19-
{
20-
return ! is_null($this->sortableColumn);
21-
}
19+
if (is_callable($column)) {
20+
$this->sortableColumn = $column;
2221

23-
public function qualifySortable(): ?string
24-
{
25-
if (! $this->isSortable()) {
26-
return null;
22+
return $this;
2723
}
2824

29-
if (Str::contains($this->sortableColumn, '.attributes')) {
30-
return $this->sortableColumn;
31-
}
25+
$this->sortableColumn = is_string($column)
26+
? $column
27+
: $this->getAttribute();
3228

33-
$table = $this->repositoryClass::newModel()->getTable();
29+
return $this;
30+
}
31+
32+
public function isSortable(RestifyRequest $request = null): bool
33+
{
34+
if (is_callable($this->sortableColumn)) {
35+
$request = $request ?: app(RestifyRequest::class);
3436

35-
if (Str::contains($this->sortableColumn, '.') && Str::startsWith($this->sortableColumn, $table)) {
36-
return $table.'.attributes.'.Str::after($this->sortableColumn, "$table.");
37+
return (bool) call_user_func($this->sortableColumn, $request, $this);
3738
}
3839

39-
return $table.'.attributes.'.$this->sortableColumn;
40+
return is_string($this->sortableColumn);
41+
}
42+
43+
public function qualifySortable(RestifyRequest $request): ?string
44+
{
45+
return $this->sortableColumn;
4046
}
4147
}

0 commit comments

Comments
 (0)