Skip to content

Commit 25b14d3

Browse files
committed
feat: order by nulls first/last (resolves #31)
1 parent 16cec84 commit 25b14d3

File tree

5 files changed

+149
-0
lines changed

5 files changed

+149
-0
lines changed

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1163,6 +1163,26 @@ $query->whereIntegerArrayMatches('tags', '3&4&(5|6)&!7');
11631163

11641164
### Order By
11651165

1166+
#### NULLS FIRST/LAST
1167+
1168+
By default, `NULL` values are sorted before everything in descending order and after everything in ascending order.
1169+
This may not be your preferred way of ordering when e.g. displaying in a table to users.
1170+
With the nulls first/last option, you can specify the exact behaviour you want:
1171+
1172+
```php
1173+
$query->orderBy($column, string $direction = 'asc'|'desc', string $nulls = 'default'|'first'|'last');
1174+
$query->orderByNullsFirst($column, string $direction = 'asc'|'desc', string $nulls = 'default'|'first'|'last');
1175+
$query->orderByNullsLast($column, string $direction = 'asc'|'desc', string $nulls = 'default'|'first'|'last');
1176+
1177+
// Sort the table by the age descending with all NULL values presented last.
1178+
$query->orderBy('age', 'desc', nulls: 'last');
1179+
$query->orderByNullsLast('age', 'desc');
1180+
```
1181+
1182+
> [!WARNING]
1183+
> You have to create a matching index when using a non-default sorting order - a standard one does not work!
1184+
> The exact index `$table->index('age DESC NULLS LAST')` matches the query or `$table->index('age NULLS FIRT')` because of the default ascending column order.
1185+
11661186
#### Vector Similarity
11671187

11681188
With the `orderByVectorSimilarity` method you can compare a column storing embeddings to other embeddings.

src/Query/BuilderOrder.php

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,64 @@
1010

1111
trait BuilderOrder
1212
{
13+
/**
14+
* Add an "order by" clause to the query.
15+
*
16+
* @param \Closure|\Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder<*>|\Illuminate\Contracts\Database\Query\Expression|string $column
17+
* @param 'asc'|'desc' $direction
18+
* @param 'default'|'first'|'last' $nulls
19+
*/
20+
public function orderBy($column, $direction = 'asc', $nulls = 'default'): static
21+
{
22+
if ($this->isQueryable($column)) {
23+
[$query, $bindings] = $this->createSub($column);
24+
25+
$column = new Expression('('.$query.')');
26+
27+
$this->addBinding($bindings, $this->unions ? 'unionOrder' : 'order');
28+
}
29+
30+
$direction = strtolower($direction);
31+
if (!\in_array($direction, ['asc', 'desc'], true)) {
32+
throw new InvalidArgumentException('Order direction must be "asc" or "desc".');
33+
}
34+
35+
$nulls = strtolower($nulls);
36+
if (!\in_array($nulls, ['default', 'first', 'last'], true)) {
37+
throw new InvalidArgumentException('Nulls direction must be "default", "first" or "last".');
38+
}
39+
40+
$this->{$this->unions ? 'unionOrders' : 'orders'}[] = [
41+
'column' => $column,
42+
'direction' => $direction,
43+
'nulls' => $nulls,
44+
];
45+
46+
return $this;
47+
}
48+
49+
/**
50+
* Add an "order by nulls first" clause to the query.
51+
*
52+
* @param \Closure|\Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder<*>|\Illuminate\Contracts\Database\Query\Expression|string $column
53+
* @param 'asc'|'desc' $direction
54+
*/
55+
public function orderByNullsFirst($column, string $direction = 'asc'): static
56+
{
57+
return $this->orderBy($column, $direction, 'first');
58+
}
59+
60+
/**
61+
* Add an "order by nulls last" clause to the query.
62+
*
63+
* @param \Closure|\Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder<*>|\Illuminate\Contracts\Database\Query\Expression|string $column
64+
* @param 'asc'|'desc' $direction
65+
*/
66+
public function orderByNullsLast($column, string $direction = 'asc'): static
67+
{
68+
return $this->orderBy($column, $direction, 'last');
69+
}
70+
1371
/**
1472
* Add a vector-similarity "order by" clause to the query.
1573
*

src/Query/Grammar.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ class Grammar extends PostgresGrammar
1111
{
1212
use GrammarCte;
1313
use GrammarFullText;
14+
use GrammarOrder;
1415
use GrammarReturning;
1516
use GrammarWhere;
1617

src/Query/GrammarOrder.php

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tpetry\PostgresqlEnhanced\Query;
6+
7+
use Illuminate\Database\Query\Builder;
8+
9+
trait GrammarOrder
10+
{
11+
/**
12+
* Compile the query orders to an array.
13+
*
14+
* @param array $orders
15+
*/
16+
protected function compileOrdersToArray(Builder $query, $orders): array
17+
{
18+
return array_map(function ($order) {
19+
$sql = $order['sql'] ?? "{$this->wrap($order['column'])} {$order['direction']}";
20+
21+
return match ($order['nulls'] ?? null) {
22+
'first' => "{$sql} nulls first",
23+
'last' => "{$sql} nulls last",
24+
default => $sql,
25+
};
26+
}, $orders);
27+
}
28+
}

tests/Query/OrderTest.php

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,48 @@
88

99
class OrderTest extends TestCase
1010
{
11+
public function testNullsDefault(): void
12+
{
13+
$this->getConnection()->unprepared('CREATE TABLE example (col int)');
14+
15+
$queries = $this->withQueryLog(function (): void {
16+
$this->getConnection()->table('example')->orderBy('col')->get();
17+
$this->getConnection()->table('example')->orderBy('col', nulls: 'default')->get();
18+
});
19+
$this->assertEquals(
20+
['select * from "example" order by "col" asc', 'select * from "example" order by "col" asc'],
21+
array_column($queries, 'query'),
22+
);
23+
}
24+
25+
public function testNullsFirst(): void
26+
{
27+
$this->getConnection()->unprepared('CREATE TABLE example (col int)');
28+
29+
$queries = $this->withQueryLog(function (): void {
30+
$this->getConnection()->table('example')->orderBy('col', nulls: 'first')->get();
31+
$this->getConnection()->table('example')->orderByNullsFirst('col')->get();
32+
});
33+
$this->assertEquals(
34+
['select * from "example" order by "col" asc nulls first', 'select * from "example" order by "col" asc nulls first'],
35+
array_column($queries, 'query'),
36+
);
37+
}
38+
39+
public function testNullsLast(): void
40+
{
41+
$this->getConnection()->unprepared('CREATE TABLE example (col int)');
42+
43+
$queries = $this->withQueryLog(function (): void {
44+
$this->getConnection()->table('example')->orderBy('col', nulls: 'last')->get();
45+
$this->getConnection()->table('example')->orderByNullsLast('col')->get();
46+
});
47+
$this->assertEquals(
48+
['select * from "example" order by "col" asc nulls last', 'select * from "example" order by "col" asc nulls last'],
49+
array_column($queries, 'query'),
50+
);
51+
}
52+
1153
public function testVectorSimilarity(): void
1254
{
1355
if (!$this->getConnection()->table('pg_available_extensions')->where('name', 'vector')->exists()) {

0 commit comments

Comments
 (0)