From 2a35b7ce4dbc281777fab4315b6c65483d8500dc Mon Sep 17 00:00:00 2001 From: Jepp9350 <2jepp9350@gmail.com> Date: Mon, 13 Jan 2025 15:50:09 +0100 Subject: [PATCH] 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. --- classes/db.php | 39 ++++--- classes/response.php | 64 +++++++---- objects/bookings_o.php | 16 ++- objects/orders_o.php | 18 +-- objects/users_o.php | 2 +- routes/bookingsRoute.php | 8 +- routes/userOrdersRoute.php | 9 +- traits/db_object_t.php | 220 +++++++++++++++++++++++++++++-------- 8 files changed, 269 insertions(+), 107 deletions(-) diff --git a/classes/db.php b/classes/db.php index e0018d85..42f2f9fe 100644 --- a/classes/db.php +++ b/classes/db.php @@ -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); + } } \ No newline at end of file diff --git a/classes/response.php b/classes/response.php index e9e90adb..ee8d6452 100644 --- a/classes/response.php +++ b/classes/response.php @@ -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; + } } \ No newline at end of file diff --git a/objects/bookings_o.php b/objects/bookings_o.php index 612179ba..615fd139 100644 --- a/objects/bookings_o.php +++ b/objects/bookings_o.php @@ -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; } diff --git a/objects/orders_o.php b/objects/orders_o.php index d0e92ab6..bfc8a5e6 100644 --- a/objects/orders_o.php +++ b/objects/orders_o.php @@ -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; } diff --git a/objects/users_o.php b/objects/users_o.php index 9558c037..918ac24c 100644 --- a/objects/users_o.php +++ b/objects/users_o.php @@ -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; diff --git a/routes/bookingsRoute.php b/routes/bookingsRoute.php index 76c1130b..bb65d3e6 100644 --- a/routes/bookingsRoute.php +++ b/routes/bookingsRoute.php @@ -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 { diff --git a/routes/userOrdersRoute.php b/routes/userOrdersRoute.php index 190f6e8b..934cbf8c 100644 --- a/routes/userOrdersRoute.php +++ b/routes/userOrdersRoute.php @@ -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); diff --git a/traits/db_object_t.php b/traits/db_object_t.php index bbb26d3c..038815aa 100644 --- a/traits/db_object_t.php +++ b/traits/db_object_t.php @@ -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