Refactor pagination and filtering logic

Replaced redundant SQL queries with reusable methods for paginating, filtering, and searching records. Improved query safety using prepared statements and enhanced flexibility with dynamic order and filter processing. Updated related routes and objects to support the new structure.
This commit is contained in:
Jepp9350
2025-01-13 15:50:09 +01:00
parent 8bde53cf82
commit 2a35b7ce4d
8 changed files with 269 additions and 107 deletions
+22 -17
View File
@@ -7,11 +7,11 @@ use mysqli;
class db
{
public mysqli $conn;
private string $host;
private string $user;
private string $password;
private string $database;
public mysqli $conn;
public function __construct(array $config)
{
@@ -31,22 +31,6 @@ class db
}
}
public function query(string $sql): \mysqli_result|bool
{
// If the connection is not established, connect
return $this->conn->query($sql);
}
public function fetch_assoc($result)
{
return $result->fetch_assoc();
}
public function fetch_all($result)
{
return $result->fetch_all(MYSQLI_ASSOC);
}
public function escape_string(string $string): string
{
return $this->conn->real_escape_string($string);
@@ -64,6 +48,17 @@ class db
return $this->fetch_assoc($result);
}
public function query(string $sql): \mysqli_result|bool
{
// If the connection is not established, connect
return $this->conn->query($sql);
}
public function fetch_assoc($result)
{
return $result->fetch_assoc();
}
public function list_objects(string $table): array
{
$sql = "SELECT * FROM $table";
@@ -71,6 +66,11 @@ class db
return $this->fetch_all($result);
}
public function fetch_all($result)
{
return $result->fetch_all(MYSQLI_ASSOC);
}
public function list_objects_paginated(string $table, int $page, int $limit): array
{
$offset = ($page - 1) * $limit;
@@ -103,4 +103,9 @@ class db
{
return $this->conn;
}
public function prepare(string $sql): false|\mysqli_stmt
{
return $this->conn->prepare($sql);
}
}
+41 -23
View File
@@ -11,6 +11,12 @@ class response implements response_i
private array $data = [];
private array $meta = [];
private array $includes = [];
#[NoReturn] public function success(mixed $data, int $status = null): void
{
$this->response(true, $data, $status);
}
#[NoReturn] public function response(bool $success, mixed $data, int $status = null): void
{
header('Content-Type: application/json');
@@ -20,7 +26,7 @@ class response implements response_i
http_response_code($success ? 200 : 400);
}
// If the data isn't an array, convert it to an array
if (! is_array($data)) {
if (!is_array($data)) {
$data = ['message' => $data];
}
echo json_encode([
@@ -32,9 +38,9 @@ class response implements response_i
exit;
}
#[NoReturn] public function success(mixed $data, int $status = null): void
#[NoReturn] public function not_found(): void
{
$this->response(true, $data, $status);
$this->error('Not found', 404);
}
#[NoReturn] public function error(mixed $data, int $status = null): void
@@ -42,31 +48,11 @@ class response implements response_i
$this->response(false, $data, $status);
}
#[NoReturn] public function not_found(): void
{
$this->error('Not found', 404);
}
#[NoReturn] public function rate_limit_exceeded(): void
{
$this->error('Rate limit exceeded', 429);
}
public function add_data(string $key, mixed $value): void
{
$this->data[$key] = $value;
}
public function add_meta(string $key, mixed $value): void
{
$this->meta[$key] = $value;
}
public function add_included(string $key, mixed $value): void
{
$this->includes[$key] = $value;
}
public function matching_route_found(): void
{
$this->matching_route_found = true;
@@ -97,6 +83,11 @@ class response implements response_i
]);
}
public function add_meta(string $key, mixed $value): void
{
$this->meta[$key] = $value;
}
public function get_data(): array
{
return $this->data;
@@ -112,6 +103,11 @@ class response implements response_i
$this->add_data('debug', $data);
}
public function add_data(string $key, mixed $value): void
{
$this->data[$key] = $value;
}
public function getRequestParameter(string $key): string|null
{
// Get the request data if the method is POST, PUT or PATCH
@@ -130,4 +126,26 @@ class response implements response_i
{
$this->add_included($string, $dataArray);
}
public function add_included(string $key, mixed $value): void
{
$this->includes[$key] = $value;
}
public function parseFilters(?string $filters): array|null
{
if ($filters) {
$filters = explode(',', $filters);
$temp = [];
foreach ( $filters as $filter ) {
$filter = explode(':', $filter);
$temp[$filter[0]] = $filter[1];
}
$filters = $temp;
}
if ($filters) {
return $filters;
}
return null;
}
}
+7 -9
View File
@@ -110,18 +110,16 @@ class bookings_o extends db
$this->status = new object_property($this->table, $this->id, 'status', 'string', true);
}
public function getCustomerBookingsPaginated(int $customer_number, int $page = 1, int $limit = 10, string $order = 'DESC'): array
public function getCustomerBookingsPaginated(int $customer_number, int $page = 1, int $limit = 10, array $order = ['id' => 'DESC'], string $search = null, array $filters = null): array
{
global /** @var response $response */
$db, $response;
$sql = "SELECT * FROM $this->table WHERE customer_number = $customer_number ORDER BY id $order LIMIT $limit OFFSET " . ($page - 1) * $limit;
$result = $db->query($sql);
$array = $db->fetch_all($result);
// Add the metadata
$sql = "SELECT COUNT(*) as count FROM $this->table WHERE customer_number = $customer_number";
$result = $db->query($sql);
$row = $db->fetch_assoc($result);
$total = $row['count'];
// Add the customer number to the filters
$filters['customer_number'] = $customer_number;
// List the objects with pagination
$array = $this->listObjectsWithPagination($page, $limit, $search, $filters, $order);
// Get the total number of objects
$total = $this->getTotalObjects($search, $filters);
$response->paginate($page, $limit, $total);
return $array;
}
+9 -9
View File
@@ -180,18 +180,18 @@ class orders_o extends db
return new users_o();
}
public function getCustomerOrdersPaginated(int $customer_number, int $page = 1, int $limit = 10, string $order = 'DESC'): array
public function getCustomerOrdersPaginated(int $customer_number, int $page = 1, int $limit = 10, array $order = ['id' => 'DESC'], string $search = null, array $filters = null): array
{
global /** @var response $response */
$db, $response;
$sql = "SELECT * FROM $this->table WHERE customer_id = $customer_number AND deleted_at IS NULL ORDER BY id $order LIMIT $limit OFFSET " . ($page - 1) * $limit;
$result = $db->query($sql);
$array = $db->fetch_all($result);
// Add the metadata
$sql = "SELECT COUNT(*) as count FROM $this->table WHERE customer_id = $customer_number AND deleted_at IS NULL";
$result = $db->query($sql);
$row = $db->fetch_assoc($result);
$total = $row['count'];
// Add the customer number to the filters
$filters['customer_id'] = $customer_number;
// List the objects with pagination
$array = $this->listObjectsWithPagination($page, $limit, $search, $filters, $order);
// Get the total number of objects
$total = $this->getTotalObjects($search, $filters);
$response->paginate($page, $limit, $total);
return $array;
}
+1 -1
View File
@@ -459,7 +459,7 @@ class users_o extends db
return true;
}
// Get the record from the database
$sql = "SELECT * FROM orders WHERE id = $order_id AND customer_number = " . $this->customer_number->value();
$sql = "SELECT * FROM orders WHERE id = $order_id AND customer_id = " . $this->customer_number->value();
$result = $db->query($sql);
if ($result->num_rows > 0) {
return true;
+6 -2
View File
@@ -47,7 +47,8 @@ class bookingsRoute
/** Own bookings */
$this->get('/user/bookings', function () {
// Require the user to be logged in
global $response;
global /** @var response $response */
$response;
$this->requirePermission('list_own_bookings');
// Get the user object
$user = (new authentication())->get_user();
@@ -61,7 +62,10 @@ class bookingsRoute
$user->customer_number->value(),
($this->fromRequest('page') ?? 1),
($this->fromRequest('limit') ?? 10),
($this->fromRequest('order') ?? 'DESC')
['id' => 'DESC'],
$this->fromRequest('search') === null ? '' : $this->fromRequest('search'),
$this->fromRequest('filters') === null ? [] :
$response->parseFilters($this->fromRequest('filters')) ?? []
)
);
} else {
+8 -1
View File
@@ -29,7 +29,10 @@ class userOrdersRoute
$user->customer_number->value(),
($this->fromRequest('page') ?? 1),
($this->fromRequest('limit') ?? 10),
($this->fromRequest('order') ?? 'DESC')
['id' => 'DESC'],
$this->fromRequest('search') === null ? '' : $this->fromRequest('search'),
$this->fromRequest('filters') === null ? [] :
$response->parseFilters($this->fromRequest('filters')) ?? []
)
);
} else {
@@ -53,6 +56,10 @@ class userOrdersRoute
if (!isset($data['id'])) {
$response->error('id parameter is required', 400);
}
// Make sure the user exists
if (!$user->exists()) {
$response->error('User not found', 400);
}
// Make sure the user is allowed to fetch the order
if (!$user->hasAccessToOrder($data['id'])) {
$response->error('You are not allowed to fetch this order', 400);
+175 -45
View File
@@ -2,6 +2,7 @@
namespace traits;
use classes\db;
use classes\response;
use Exception;
@@ -68,6 +69,7 @@ trait db_object_t
$limit = ((int)$response->getRequestParameter('limit')) ?? null; // Get the number of objects per page
$search = $response->getRequestParameter('search') ?? null; // Get the search query
$filters = $response->getRequestParameter('filters') ?? null; // Get the filters ( Eg. department_id:1,role_id:2 OR department_id:1 )
$order = $response->getRequestParameter('order') ?? null; // Get the order ( Eg. name:ASC )
// Make the filters an array
if ($filters) {
$filters = explode(',', $filters);
@@ -78,8 +80,14 @@ trait db_object_t
}
$filters = $temp;
}
// Make the order an array
if ($order) {
$order = explode(':', $order);
$order = [$order[0] => $order[1]];
}
// List the objects with pagination
if ($page && $limit) {
return $this->listObjectsWithPagination($page, $limit, $search, $filters);
return $this->listObjectsWithPagination($page, $limit, $search, $filters, $order);
}
return $this->listObjects();
}
@@ -90,62 +98,114 @@ trait db_object_t
* @param int $limit The number of objects per page
* @return array The list of objects in the table
*/
public function listObjectsWithPagination(int $page, int $limit, string $search = null, array $filters = null): array
public function listObjectsWithPagination(int $page, int $limit, ?string $search = null, ?array $filters = null, ?array $order = null): array
{
global /** @var response $response */
/** @var db $db */
$db, $response;
$mysqli = $db->conn;
$offset = ($page - 1) * $limit;
// Get all the fields of the table
$sql = "SHOW COLUMNS FROM $this->table";
$result = $db->query($sql);
$fields = $db->fetch_all($result);
$searchfields = [];
foreach ( $fields as $field ) {
$searchfields[] = $field['Field'];
// Fetch table fields
$fields = [];
$result = $mysqli->query("SHOW COLUMNS FROM {$this->table}");
while ($row = $result->fetch_assoc()) {
$fields[] = $row['Field'];
}
// Search for any similarities to the search query (Not case sensitive)
$searchQuery = '';
if ($search) {
$searchQuery = 'WHERE ';
$search = strtolower($search);
$searchQuery .= '(';
foreach ( $searchfields as $field ) {
$searchQuery .= "LOWER($field) LIKE '%$search%' OR ";
$result->free();
$whereClauses = [];
$params = [];
// Search clause
if (!empty($search)) {
// Use prepared statements to prevent SQL injection
$searchClauses = [];
foreach ( $fields as $field ) {
$searchClauses[] = "`$field` LIKE ?";
$params[] = "%$search%";
}
$searchQuery = substr($searchQuery, 0, -4);
$searchQuery .= ')';
$whereClauses[] = '(' . implode(' OR ', $searchClauses) . ')';
}
// Filter the objects
if ($filters) {
if ($searchQuery) {
$searchQuery .= ' AND ';
} else {
$searchQuery = 'WHERE ';
}
// Filters clause
if (!empty($filters)) {
foreach ( $filters as $field => $value ) {
// If the value is a number, do not add quotes
if (is_numeric($value)) {
$searchQuery .= "$field = $value AND ";
} else {
$searchQuery .= "$field = '$value' AND ";
if (in_array($field, $fields)) {
$whereClauses[] = "`$field` = ?";
$params[] = $value;
}
}
// If the deleted_at column exists, filter out the deleted objects
if (in_array('deleted_at', $searchfields)) {
$searchQuery .= "deleted_at IS NULL AND ";
}
$searchQuery = substr($searchQuery, 0, -5);
}
// Get the objects with pagination
$sql = "SELECT * FROM $this->table $searchQuery LIMIT $limit OFFSET $offset";
$result = $db->query($sql);
$array = $db->fetch_all($result);
$sql = "SELECT COUNT(*) AS count FROM $this->table $searchQuery";
$result = $db->query($sql);
$row = $db->fetch_assoc($result);
$total = $row['count'];
// Check for "deleted_at" column
if (in_array('deleted_at', $fields)) {
$whereClauses[] = "`deleted_at` IS NULL";
}
// Combine WHERE clauses
$whereQuery = !empty($whereClauses) ? 'WHERE ' . implode(' AND ', $whereClauses) : '';
// Order clause
$orderQuery = '';
if (!empty($order)) {
$orderParts = [];
foreach ( $order as $field => $direction ) {
if (in_array($field, $fields)) {
$direction = strtoupper($direction) === 'DESC' ? 'DESC' : 'ASC';
$orderParts[] = "`$field` $direction";
}
}
$orderQuery = !empty($orderParts) ? 'ORDER BY ' . implode(', ', $orderParts) : '';
}
// Pagination clause
$limitQuery = 'LIMIT ? OFFSET ?';
$params[] = $limit;
$params[] = $offset;
// Final query
$sql = "SELECT * FROM {$this->table} $whereQuery $orderQuery $limitQuery";
// Prepare and bind
$stmt = $mysqli->prepare($sql);
if ($stmt === false) {
throw new Exception("Failed to prepare statement: " . $mysqli->error);
}
// Dynamically bind parameters
$types = str_repeat('s', count($params) - 2) . 'ii'; // 's' for strings, 'i' for limit and offset
$stmt->bind_param($types, ...$params);
$stmt->execute();
$result = $stmt->get_result();
$objects = $result->fetch_all(MYSQLI_ASSOC);
$stmt->close();
// Total count query
$countSql = "SELECT COUNT(*) AS count FROM {$this->table} $whereQuery";
$countStmt = $mysqli->prepare($countSql);
if ($countStmt === false) {
throw new Exception("Failed to prepare count statement: " . $mysqli->error);
}
// Reuse parameters (excluding LIMIT and OFFSET)
$countParams = array_slice($params, 0, -2);
$countTypes = substr($types, 0, count($countParams));
if (!empty($countParams)) {
$countStmt->bind_param($countTypes, ...$countParams);
}
$countStmt->execute();
$countResult = $countStmt->get_result();
$total = $countResult->fetch_assoc()['count'];
$countStmt->close();
// Provide pagination metadata
$response->paginate($page, $limit, $total, $search, $filters);
return $array;
return $objects;
}
/**
@@ -160,6 +220,76 @@ trait db_object_t
return $db->fetch_all($result);
}
/**
* Get the total number of objects
* @param string|null $search The search query
* @param array|null $filters The filters ( Eg. department_id:1,role_id:2 OR department_id:1 )
* @return int The total number of objects
*/
public function getTotalObjects(string $search = null, array $filters = null): int
{
global $db;
$mysqli = $db->conn;
// Fetch table fields
$fields = [];
$result = $mysqli->query("SHOW COLUMNS FROM {$this->table}");
while ($row = $result->fetch_assoc()) {
$fields[] = $row['Field'];
}
$result->free();
$whereClauses = [];
$params = [];
// Search clause
if (!empty($search)) {
// Use prepared statements to prevent SQL injection
$searchClauses = [];
foreach ( $fields as $field ) {
$searchClauses[] = "`$field` LIKE ?";
$params[] = "%$search%";
}
$whereClauses[] = '(' . implode(' OR ', $searchClauses) . ')';
}
// Filters clause
if (!empty($filters)) {
foreach ( $filters as $field => $value ) {
if (in_array($field, $fields)) {
$whereClauses[] = "`$field` = ?";
$params[] = $value;
}
}
}
// Combine WHERE clauses
$whereQuery = !empty($whereClauses) ? 'WHERE ' . implode(' AND ', $whereClauses) : '';
// Check for "deleted_at" column
if (in_array('deleted_at', $fields)) {
$whereClauses[] = "`deleted_at` IS NULL";
}
// Final query
$sql = "SELECT COUNT(*) AS count FROM {$this->table} $whereQuery";
// Prepare and bind
$stmt = $mysqli->prepare($sql);
if ($stmt === false) {
throw new Exception("Failed to prepare statement: " . $mysqli->error);
}
// Dynamically bind parameters
$types = str_repeat('s', count($params)); // 's' for strings
$stmt->bind_param($types, ...$params);
$stmt->execute();
$result = $stmt->get_result();
return $result->fetch_assoc()['count'];
}
/**
* Does this object exist in the database?
* @return bool True if the object exists in the database, false otherwise