Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Fix] Sort nulls last for pool publish date #12186

Merged
merged 4 commits into from
Dec 5, 2024
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
40 changes: 40 additions & 0 deletions api/app/Builders/PoolBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,46 @@ public function orderByPoolBookmarks(?array $args): self
return $this;
}

// A scope for a simple orderBy on a column. Allows for nulls first or last.
public function orderByColumn(?array $args): self
{
$column = $args['column'];
$order = $args['order'];
$nulls = $args['nulls'] ?? null;

// verify if column name is valid
/** @var \App\Models\Pool */
$model = $this->model;
$selectableColumns = $model->getSelectableColumns();
if (! in_array($column, $selectableColumns)) {
throw new \Exception('Invalid column');
}

// build column name qualified with table name
$tableName = $this->model->getTable();
$columnSql = "\"$tableName\".\"$column\"";

// build order direction while verifying that option is valid
$orderOptionSql = match ($order) {
'ASC' => 'ASC',
'DESC' => 'DESC',
default => throw new \Exception('Invalid order option'),
};

// build nulls option while verifying that option is valid
$nullsOptionSql = match ($nulls) {
'ORDER_FIRST' => 'NULLS FIRST',
'ORDER_LAST' => 'NULLS LAST',
null => '',
default => throw new \Exception('Invalid nulls option'),
};

// SQL execution from user input! Ensure sufficient sanitization.
$this->orderByRaw("$columnSql $orderOptionSql $nullsOptionSql");

return $this;
}

/**
* Filter for pools the user is allowed to admin, based on scopeAuthorizedToAdmin
*/
Expand Down
9 changes: 9 additions & 0 deletions api/app/Enums/NullsOption.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

namespace App\Enums;

enum NullsOption
{
case ORDER_FIRST;
case ORDER_LAST;
}
7 changes: 7 additions & 0 deletions api/graphql/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -864,6 +864,12 @@ input PoolBookmarksOrderByInput {
order: SortOrder!
}

input OrderByColumnInput {
column: String!
order: SortOrder!
nulls: NullsOption
}

# Changes to this input will require some manual updates to api/app/GraphQL/Queries/CountPoolCandidatesByPool.php since it doesn't use the directives for automatic resolution
input ApplicantFilterInput {
equity: EquitySelectionsInput @scope
Expand Down Expand Up @@ -1012,6 +1018,7 @@ type Query {
where: PoolFilterInput
orderByPoolBookmarks: PoolBookmarksOrderByInput @scope
orderByTeamDisplayName: PoolTeamDisplayNameOrderByInput @scope
orderByColumn: OrderByColumnInput @scope
orderBy: _
@orderBy(
relations: [
Expand Down
12 changes: 12 additions & 0 deletions api/storage/app/lighthouse-schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,7 @@ type Query {
where: PoolFilterInput
orderByPoolBookmarks: PoolBookmarksOrderByInput
orderByTeamDisplayName: PoolTeamDisplayNameOrderByInput
orderByColumn: OrderByColumnInput
orderBy: [QueryPoolsPaginatedOrderByRelationOrderByClause!]

"Limits number of fetched items. Maximum allowed value: 1000."
Expand Down Expand Up @@ -1211,6 +1212,12 @@ input PoolBookmarksOrderByInput {
order: SortOrder!
}

input OrderByColumnInput {
column: String!
order: SortOrder!
nulls: NullsOption
}

input ApplicantFilterInput {
equity: EquitySelectionsInput
hasDiploma: Boolean @deprecated(reason: "hasDiploma to be replaced")
Expand Down Expand Up @@ -3117,6 +3124,11 @@ enum NotificationFamily {
JOB_ALERT
}

enum NullsOption {
ORDER_FIRST
ORDER_LAST
}

enum OperationalRequirement {
SHIFT_WORK
ON_CALL
Expand Down
154 changes: 154 additions & 0 deletions api/tests/Unit/PoolBuilderTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
<?php

namespace Tests\Unit;

use App\Models\Pool;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Tests\TestCase;
use Throwable;

use function PHPUnit\Framework\assertEquals;

class PoolBuilderTest extends TestCase
{
use RefreshDatabase;
use WithFaker;

protected function setUp(): void
{
parent::setUp();

$this->seed(RolePermissionSeeder::class);
}

public function testOrderByColumnSortsDescWithNullsLast()
{
Pool::factory()->create([
'id' => '00000000-0000-0000-0000-000000000001',
'published_at' => null,
]);
Pool::factory()->create([
'id' => '00000000-0000-0000-0000-000000000002',
'published_at' => '2020-01-01',
]);
Pool::factory()->create([
'id' => '00000000-0000-0000-0000-000000000003',
'published_at' => '2021-01-01',
]);

$sorted = Pool::orderByColumn([
'column' => 'published_at',
'order' => 'DESC',
'nulls' => 'ORDER_LAST',
])->get(['id'])->pluck('id')->toArray();

assertEquals($sorted, [
'00000000-0000-0000-0000-000000000003',
'00000000-0000-0000-0000-000000000002',
'00000000-0000-0000-0000-000000000001', // null forced last
]);
}

public function testOrderByColumnSortsDescWithNullsDefault()
{
Pool::factory()->create([
'id' => '00000000-0000-0000-0000-000000000003',
'published_at' => '2021-01-01',
]);
Pool::factory()->create([
'id' => '00000000-0000-0000-0000-000000000002',
'published_at' => '2020-01-01',
]);
Pool::factory()->create([
'id' => '00000000-0000-0000-0000-000000000001',
'published_at' => null,
]);

$sorted = Pool::orderByColumn([
'column' => 'published_at',
'order' => 'DESC',
])->get(['id'])->pluck('id')->toArray();

assertEquals($sorted, [
'00000000-0000-0000-0000-000000000001', // null first by default, for DESC
'00000000-0000-0000-0000-000000000003',
'00000000-0000-0000-0000-000000000002',
]);
}

public function testOrderByColumnSortsAscWithNullsFirst()
{
Pool::factory()->create([
'id' => '00000000-0000-0000-0000-000000000003',
'published_at' => '2021-01-01',
]);
Pool::factory()->create([
'id' => '00000000-0000-0000-0000-000000000002',
'published_at' => '2020-01-01',
]);
Pool::factory()->create([
'id' => '00000000-0000-0000-0000-000000000001',
'published_at' => null,
]);

$sorted = Pool::orderByColumn([
'column' => 'published_at',
'order' => 'ASC',
'nulls' => 'ORDER_FIRST',
])->get(['id'])->pluck('id')->toArray();

assertEquals($sorted, [
'00000000-0000-0000-0000-000000000001', // null forced first
'00000000-0000-0000-0000-000000000002',
'00000000-0000-0000-0000-000000000003',
]);
}

public function testOrderByColumnRejectsBadColumn()
{
try {
Pool::orderByColumn([
'column' => 'bad_column',
'order' => 'ASC',
]);
} catch (Throwable $e) {
assertEquals('Invalid column', $e->getMessage());

return;
}
$this->fail('Expected exception');
}

public function testOrderByColumnRejectsBadOrder()
{
try {
Pool::orderByColumn([
'column' => 'published_at',
'order' => 'bad_order',
]);
} catch (Throwable $e) {
assertEquals('Invalid order option', $e->getMessage());

return;
}
$this->fail('Expected exception');
}

public function testOrderByColumnRejectsBadNullsOption()
{
try {
Pool::orderByColumn([
'column' => 'published_at',
'order' => 'ASC',
'nulls' => 'bad_nulls',
]);
} catch (Throwable $e) {
assertEquals('Invalid nulls option', $e->getMessage());

return;
}
$this->fail('Expected exception');
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import {
poolBookmarkHeader,
poolBookmarkCell,
getPoolBookmarkSort,
getOrderByColumnSort,
} from "./helpers";
import PoolFilterDialog, { FormValues } from "./PoolFilterDialog";
import { PoolBookmark_Fragment } from "./PoolBookmark";
Expand Down Expand Up @@ -128,6 +129,7 @@ const PoolTable_Query = graphql(/* GraphQL */ `
$where: PoolFilterInput
$orderByPoolBookmarks: PoolBookmarksOrderByInput
$orderByTeamDisplayName: PoolTeamDisplayNameOrderByInput
$orderByColumn: OrderByColumnInput
$orderBy: [QueryPoolsPaginatedOrderByRelationOrderByClause!]
$first: Int
$page: Int
Expand All @@ -143,6 +145,7 @@ const PoolTable_Query = graphql(/* GraphQL */ `
where: $where
orderByPoolBookmarks: $orderByPoolBookmarks
orderByTeamDisplayName: $orderByTeamDisplayName
orderByColumn: $orderByColumn
orderBy: $orderBy
first: $first
page: $page
Expand Down Expand Up @@ -254,6 +257,7 @@ const PoolTable = ({ title, initialFilterInput }: PoolTableProps) => {
first: paginationState.pageSize,
orderByPoolBookmarks: getPoolBookmarkSort(),
orderByTeamDisplayName: getTeamDisplayNameSort(sortState, locale),
orderByColumn: getOrderByColumnSort(sortState),
orderBy: sortState ? getOrderByClause(sortState) : undefined,
},
});
Expand Down
36 changes: 28 additions & 8 deletions apps/web/src/pages/Pools/IndexPoolPage/components/helpers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
FragmentType,
LocalizedString,
Maybe,
NullsOption,
OrderByColumnInput,
OrderByRelationWithColumnAggregateFunction,
Pool,
PoolBookmarksOrderByInput,
Expand Down Expand Up @@ -175,7 +177,7 @@ export function getOrderByClause(
["processNumber", "process_number"],
["ownerName", "FIRST_NAME"],
["ownerEmail", "EMAIL"],
["publishedAt", "published_at"],
// ["publishedAt", "published_at"], // moved to getOrderByColumnSort to handle nulls
["createdDate", "created_at"],
["updatedDate", "updated_at"],
["classification", "classification"],
Expand Down Expand Up @@ -240,13 +242,8 @@ export function getOrderByClause(
];
}

return [
{
column: "created_at",
order: SortOrder.Asc,
user: undefined,
},
];
// nothing matched
return undefined;
}

export function getTeamDisplayNameSort(
Expand All @@ -263,6 +260,29 @@ export function getTeamDisplayNameSort(
};
}

export function getOrderByColumnSort(
sortingRules?: SortingState,
): OrderByColumnInput | undefined {
// few columns use this ordering clause
const columnMap = new Map<string, string>([["publishedAt", "published_at"]]);

const sortingRule = sortingRules?.find((rule) => {
const columnName = columnMap.get(rule.id);
return !!columnName;
});

if (sortingRule?.id === "publishedAt") {
return {
column: "published_at",
order: sortingRule.desc ? SortOrder.Desc : SortOrder.Asc,
nulls: NullsOption.OrderLast,
};
}

// nothing matched
return undefined;
}

export function getPoolBookmarkSort(): PoolBookmarksOrderByInput | undefined {
return {
column: "poolBookmarks",
Expand Down
Loading