1459 lines
60 KiB
PHP
1459 lines
60 KiB
PHP
<?php
|
|
|
|
namespace traits;
|
|
|
|
use attachments\helpers\attachment;
|
|
use attachments\helpers\attachment_content;
|
|
use classes\attachments;
|
|
use classes\db;
|
|
use classes\object_property;
|
|
use classes\response;
|
|
use classes\system_search_cache;
|
|
use Exception;
|
|
use mysqli_result;
|
|
use objects\bookings_new_o;
|
|
use objects\bookings_o;
|
|
use objects\branding_o;
|
|
use objects\categories_o;
|
|
use objects\collected_order_invoices_o;
|
|
use objects\cron_o;
|
|
use objects\currency_conversion_rates_o;
|
|
use objects\customer_codes_o;
|
|
use objects\customer_notes_o;
|
|
use objects\customer_vehicles_o;
|
|
use objects\department_categories_o;
|
|
use objects\department_daily_reports_o;
|
|
use objects\department_variables_o;
|
|
use objects\departments_o;
|
|
use objects\economic_module_orders;
|
|
use objects\form_submissions_o;
|
|
use objects\fxratesapi_conversion_rates_o;
|
|
use objects\groups_o;
|
|
use objects\groups_permissions_o;
|
|
use objects\logs_o;
|
|
use objects\module_action_logs_o;
|
|
use objects\motorapi_lookups_o;
|
|
use objects\notifications_o;
|
|
use objects\object_attachments_o;
|
|
use objects\order_items_o;
|
|
use objects\orders_o;
|
|
use objects\plate_scanners_o;
|
|
use objects\plate_scans_o;
|
|
use objects\product_options_o;
|
|
use objects\products_o;
|
|
use objects\ratelimit_o;
|
|
use objects\stripe_module_customers_o;
|
|
use objects\stripe_module_orders_o;
|
|
use objects\tokens_o;
|
|
use objects\user_key_value_pairs_o;
|
|
use objects\user_price_overrides_o;
|
|
use objects\users_o;
|
|
use Throwable;
|
|
|
|
trait db_object_t
|
|
{
|
|
public static string $asArrayCacheKey = 'as_array'; // The cache key for the asArray function
|
|
public static int $cashierNameCacheExpiration = 300; // The cashier name cache expiration time, in seconds. Default is 1 day (86400 seconds).
|
|
public static int $economicCustomerNameCacheExpiration = 86400; // The economic customer name cache expiration time, in seconds. Default is 1 day (86400 seconds).
|
|
public static int $asArrayCacheExpiration = 600; // The id of the object in the database
|
|
public static int $isBookedCacheExpiration = 300; // The booked / not booked cache expiration time, in seconds. Default is 1 minute (60 seconds).
|
|
public int $id; // The table of the objects in the database (e.g. users)
|
|
private string $table; // The fields to search in the database (e.g. ['name', 'email']). If empty, all fields will be searched
|
|
private array $searchableFields = []; // The where clauses to add to the pagination query
|
|
private array $whereClauses = []; // The cache expiration time for the asArray function, in seconds. This is used to cache the result of the asArray function to improve performance. Default is 10 minutes (600 seconds).
|
|
/**
|
|
* Example:
|
|
* // Set the CustomerId to be not "NULL".
|
|
* setAdditionalWhereClause('AND `CustomerId` IS NOT NULL');
|
|
* @var string
|
|
*/
|
|
private string $additionalWhereClause = ''; // Additional where clause to add to the pagination query, this is used to add custom where clauses to the pagination query
|
|
|
|
private function markSystemSearchDirtyTable(?string $table = null): void
|
|
{
|
|
try {
|
|
$target = $table ?? $this->table;
|
|
$target = trim((string)$target, " `\t\n\r\0\x0B");
|
|
if ($target === '') {
|
|
return;
|
|
}
|
|
system_search_cache::markDirtyTable($target);
|
|
} catch (Throwable) {
|
|
// Search invalidation must never block write operations.
|
|
}
|
|
}
|
|
|
|
public function __construct()
|
|
{
|
|
$this->structure();
|
|
}
|
|
|
|
/**
|
|
* Structure: Define the table and fields of the objects in the database
|
|
*/
|
|
public function structure(): void
|
|
{
|
|
// Define the table and fields of the objects in the database
|
|
}
|
|
|
|
/**
|
|
* Send a custom where clause to get objects as objects
|
|
* @param string $whereClause The where clause to use (e.g. "status = 'active' AND role = 'admin'")
|
|
* @return self[] The list of objects that match the where clause
|
|
* @throws Exception If the where clause is invalid
|
|
* @note This function does not use prepared statements, so be careful when using it to avoid SQL injection.
|
|
* @deprecated Use getFieldsWhere or getFieldsWhereIn instead for better security and flexibility. This function is extremely prone to SQL injection attacks.
|
|
*/
|
|
public function getObjectsWhereClause(string $whereClause): array
|
|
{
|
|
global $db;
|
|
$table = $this->table;
|
|
$sql = "SELECT id FROM $table WHERE $whereClause";
|
|
$result = $db->query($sql);
|
|
$rows = $db->fetch_all($result);
|
|
$objects = [];
|
|
foreach ( $rows as $row ) {
|
|
$object = new static();
|
|
$object->id = (int)$row['id'];
|
|
$objects[] = $object;
|
|
}
|
|
return $objects;
|
|
}
|
|
|
|
public function countRowsWhere(array $fieldsAndValues): int
|
|
{
|
|
global $db;
|
|
$table = $this->table;
|
|
$where = [];
|
|
foreach ( $fieldsAndValues as $field => $value ) {
|
|
$where[] = "$field = '$value'";
|
|
}
|
|
$where = implode(' AND ', $where);
|
|
$sql = "SELECT COUNT(*) AS count FROM $table WHERE $where";
|
|
$result = $db->query($sql);
|
|
$row = $db->fetch_assoc($result);
|
|
return $row['count'];
|
|
}
|
|
|
|
public function getFieldsWhere(array $fieldsAndValues, array $fields): array
|
|
{
|
|
global $db;
|
|
$table = $this->table;
|
|
$where = [];
|
|
foreach ( $fieldsAndValues as $field => $value ) {
|
|
// If the value is null, add a where clause to check if the field is null
|
|
if ($value === null) {
|
|
$where[] = "$field IS NULL";
|
|
} elseif (is_string($value) && strtolower($value) === '!null') {
|
|
// If the value is "!null", add a where clause to check if the field is not null
|
|
$where[] = "$field IS NOT NULL";
|
|
} elseif (is_array($value)) {
|
|
// If the value is an array, add a where clause to check if the field is in the array
|
|
$in = implode(',', array_map(function ($v) {
|
|
// Escape the value to prevent SQL injection
|
|
global $db;
|
|
// If the value is numeric, return it as is
|
|
if (is_numeric($v)) {
|
|
return $v;
|
|
}
|
|
return "'" . $db->escape_string($v) . "'";
|
|
}, $value));
|
|
$where[] = "$field IN ($in)";
|
|
} elseif (is_numeric($value)) {
|
|
// If the value is numeric, add a where clause to check if the field is equal to the value
|
|
$where[] = "$field = $value";
|
|
} else {
|
|
// Escape the value to prevent SQL injection
|
|
global $db;
|
|
$value = $db->escape_string($value);
|
|
$where[] = "$field = '$value'";
|
|
}
|
|
}
|
|
$where = implode(' AND ', $where);
|
|
$sql = "SELECT " . implode(', ', $fields) . " FROM $table WHERE $where";
|
|
$result = $db->query($sql);
|
|
return $db->fetch_all($result);
|
|
}
|
|
|
|
public function getFieldsWhereIn(array $fieldsAndValues, array $fields): array
|
|
{
|
|
global $db;
|
|
$table = $this->table;
|
|
$where = [];
|
|
foreach ( $fieldsAndValues as $field => $value ) {
|
|
if (is_array($value)) {
|
|
// Check if the array is empty
|
|
if (empty($value)) {
|
|
// If the array is empty, return an empty array
|
|
return [];
|
|
}
|
|
// If the value is an array, add a where clause to check if the field is in the array
|
|
$in = implode(',', array_map(function ($v) use ($field) {
|
|
// Escape the value to prevent SQL injection (if the value is a string)
|
|
if (is_numeric($v)) {
|
|
return $v;
|
|
}
|
|
if ($v === null) {
|
|
return 'NULL';
|
|
}
|
|
if (is_string($v) && strtolower($v) === 'null') {
|
|
return 'NULL';
|
|
}
|
|
if (is_string($v) && strtolower($v) === '!null') {
|
|
throw new Exception('Invalid value in array for field: ' . $field . '. Cannot use "!null" in an array.');
|
|
}
|
|
global $db;
|
|
return "'" . $db->escape_string($v) . "'";
|
|
}, $value));
|
|
$where[] = "$field IN ($in)";
|
|
} else {
|
|
// If the value is an integer, add a where clause to check if the field is equal to the value
|
|
if (is_numeric($value)) {
|
|
$where[] = "$field = $value";
|
|
continue;
|
|
}
|
|
// If the value is null, add a where clause to check if the field is null
|
|
if ($value === null) {
|
|
$where[] = "$field IS NULL";
|
|
continue;
|
|
}
|
|
// If the value is "!null", add a where clause to check if the field is not null
|
|
if (is_string($value) && strtolower($value) === '!null') {
|
|
$where[] = "$field IS NOT NULL";
|
|
continue;
|
|
}
|
|
// Escape the value to prevent SQL injection
|
|
$value = $db->escape_string($value);
|
|
$where[] = "$field = '$value'";
|
|
}
|
|
}
|
|
$where = implode(' AND ', $where);
|
|
$sql = "SELECT " . implode(', ', $fields) . " FROM $table WHERE $where";
|
|
$result = $db->query($sql);
|
|
return $db->fetch_all($result);
|
|
}
|
|
|
|
public function getFieldsWhereContaining(array $fieldsAndValues, array $fields, array $options = ['limit' => null, 'offset' => null]): array
|
|
{
|
|
global $db;
|
|
$table = $this->table;
|
|
$where = [];
|
|
foreach ( $fieldsAndValues as $field => $value ) {
|
|
// Check if the value is null
|
|
if ($value === null || $value === 'null') {
|
|
$where[] = "$field IS NULL";
|
|
continue;
|
|
}
|
|
// Escape the value to prevent SQL injection
|
|
$value = $db->escape_string($value);
|
|
$where[] = "$field LIKE '%$value%'";
|
|
}
|
|
$where = implode(' AND ', $where);
|
|
$sql = "SELECT " . implode(', ', $fields) . " FROM $table WHERE $where";
|
|
// If the limit is set, add it to the query
|
|
if (isset($options['limit']) && is_int($options['limit']) && $options['limit'] > 0) {
|
|
$sql .= " LIMIT " . $options['limit'];
|
|
}
|
|
// If the offset is set, add it to the query
|
|
if (isset($options['offset']) && is_int($options['offset']) && $options['offset'] >= 0) {
|
|
$sql .= " OFFSET " . $options['offset'];
|
|
}
|
|
$result = $db->query($sql);
|
|
return $db->fetch_all($result);
|
|
}
|
|
|
|
/**
|
|
* Update fields in the database where the conditions are met
|
|
* @param array $fieldsAndValues The fields and values to update (e.g. ['name' => 'John Doe', 'email' => 'test@email.com'])
|
|
* @param array $conditions The conditions to meet for the update (e.g. ['id' => 1, 'status' => 'active'])
|
|
* @param int $limit The limit of rows to update, default is 0 (0 means no limit, update all rows that match the conditions)
|
|
* @return mysqli_result|bool Returns the result of the update query, or false on failure
|
|
*/
|
|
public function updateFieldsWhere(array $fieldsAndValues, array $conditions, int $limit = 1): mysqli_result|bool
|
|
{
|
|
global /** @var db $db */
|
|
$db;
|
|
$table = $this->table;
|
|
$set = [];
|
|
foreach ( $fieldsAndValues as $field => $value ) {
|
|
// Escape the value to prevent SQL injection
|
|
if (is_null($value)) {
|
|
$set[] = "$field = NULL";
|
|
continue;
|
|
}
|
|
$value = $db->escape_string($value);
|
|
$set[] = "$field = '$value'";
|
|
}
|
|
$set = implode(', ', $set);
|
|
|
|
// Build the WHERE clause
|
|
$where = [];
|
|
foreach ( $conditions as $field => $value ) {
|
|
// Escape the value to prevent SQL injection
|
|
if (is_null($value)) {
|
|
$where[] = "$field IS NULL";
|
|
} else {
|
|
$value = $db->escape_string($value);
|
|
$where[] = "$field = '$value'";
|
|
}
|
|
}
|
|
$where = implode(' AND ', $where);
|
|
// If the limit is set, add it to the query
|
|
if ($limit > 0) {
|
|
$where .= " LIMIT $limit";
|
|
}
|
|
|
|
// Execute the update query
|
|
$sql = "UPDATE $table SET $set WHERE $where";
|
|
$result = $db->query($sql);
|
|
if ($result !== false) {
|
|
$this->markSystemSearchDirtyTable($table);
|
|
}
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* Set the searchable fields
|
|
* @param array $fields The fields to search in the database (e.g. ['name', 'email'])
|
|
* @return users_o|bookings_o|cron_o|customer_codes_o|customer_notes_o|customer_vehicles_o|departments_o|economic_module_orders|logs_o|order_items_o|orders_o|plate_scanners_o|plate_scans_o|products_o|ratelimit_o|tokens_o|user_key_value_pairs_o|user_price_overrides_o|db_object_t
|
|
*/
|
|
public function setSearchableFields(array $fields): self
|
|
{
|
|
$this->searchableFields = $fields;
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Add a where clause to the pagination query
|
|
* @param string $field
|
|
* @param mixed $value
|
|
* @return users_o|bookings_o|categories_o|cron_o|customer_codes_o|customer_notes_o|customer_vehicles_o|department_categories_o|departments_o|economic_module_orders|logs_o|order_items_o|orders_o|plate_scanners_o|plate_scans_o|product_options_o|products_o|ratelimit_o|tokens_o|user_key_value_pairs_o|user_price_overrides_o|db_object_t
|
|
*/
|
|
public function addWhereClause(string $field, mixed $value): self
|
|
{
|
|
$this->whereClauses[] = "$field = '$value'";
|
|
return $this;
|
|
}
|
|
|
|
public function __toString(): string
|
|
{
|
|
self::requireSelected();
|
|
// Return the object as a string
|
|
// Check if the asArray function is set, if not, return the object row directly
|
|
if (method_exists($this, 'asArray')) {
|
|
return json_encode($this->asArray());
|
|
}
|
|
// If the asArray function is not set, return the object row directly
|
|
return json_encode($this->getArray());
|
|
}
|
|
|
|
|
|
/**
|
|
* Require selected object.
|
|
* Will throw an exception if object is not selected.
|
|
*/
|
|
public function requireSelected(): void
|
|
{
|
|
if (!isset($this->id) || !$this->id) {
|
|
// Figure out where the id was required from
|
|
$trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2);
|
|
throw new Exception('Object not selected or does not exist in ' . $this->table . ' (required from ' . ($trace[1]['function'] ?? 'unknown') . ' function)');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get current object row as an array
|
|
* @return array The current object row as an array
|
|
* @throws Exception If object not found
|
|
*/
|
|
public function getArray(): array
|
|
{
|
|
// Get the object from the database
|
|
global $db;
|
|
// Check if the id is set, if not throw an exception
|
|
if (!isset($this->id)) {
|
|
throw new Exception('Object not found');
|
|
}
|
|
$id = $this->id;
|
|
$sql = "SELECT * FROM $this->table WHERE id = $id";
|
|
$result = $db->query($sql);
|
|
return $db->fetch_assoc($result);
|
|
}
|
|
|
|
/**
|
|
* List objects with pagination (if set)
|
|
* @param callable|null $parseFunction The function to parse the objects
|
|
* @param string|null $forcedFilters The filters to force on the objects (Use the array_to_filters function to convert an array to a string)
|
|
* @return array The list of objects in the table
|
|
* @throws Exception
|
|
*/
|
|
public function listObjectsWithPaginationIfSet($parseFunction = null, $forcedFilters = null, array $join = [], $additionalWhere = null): array
|
|
{
|
|
// Link to the listObjectsWithPaginationIfSet function.
|
|
return self::db_object_t__listObjectsWithPaginationIfSet($parseFunction, $forcedFilters, $join, $additionalWhere);
|
|
}
|
|
|
|
/**
|
|
* Set the additional where clause to add to the pagination query
|
|
* @param string $clause The additional where clause to add to the pagination query
|
|
* Example:
|
|
* setAdditionalWhereClause('AND `CustomerId` IS NOT NULL');
|
|
* @note This is cleared after each pagination query, so it can be used to add custom where clauses to the pagination query
|
|
*/
|
|
public function setAdditionalWhereClause(string $clause): self
|
|
{
|
|
// Add the additional where clause to the pagination query
|
|
$this->additionalWhereClause = $clause;
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* List objects with pagination (if set)
|
|
* @note This is separate from listObjectsWithPaginationIfSet, as it is used in the API routes with specific permission checks
|
|
* @param callable|null $parseFunction The function to parse the objects
|
|
* @param string|null $forcedFilters The filters to force on the objects (Use the array_to_filters function to convert an array to a string)
|
|
* @return array
|
|
* @throws Exception If the user does not have permission to list the objects
|
|
*/
|
|
public function db_object_t__listObjectsWithPaginationIfSet($parseFunction = null, $forcedFilters = null, array $join = [], $additionalWhere = null): array
|
|
{
|
|
global $response;
|
|
$page = ((int)$response->getRequestParameter('page')) ?? null; // Get the page number
|
|
$limit = ((int)$response->getRequestParameter('limit')) ?? null; // Get the number of objects per page
|
|
$search = $response->getRequestParameter('search') ?? null; // Get the search query
|
|
// Apply additional where clause if provided via helper
|
|
if ($additionalWhere !== null) {
|
|
$additionalWhereSql = '';
|
|
if (is_string($additionalWhere)) {
|
|
$additionalWhereSql = $additionalWhere;
|
|
} elseif (is_object($additionalWhere)) {
|
|
if (method_exists($additionalWhere, 'toSql')) {
|
|
$additionalWhereSql = (string)$additionalWhere->toSql();
|
|
} elseif (method_exists($additionalWhere, '__toString')) {
|
|
$additionalWhereSql = (string)$additionalWhere;
|
|
}
|
|
}
|
|
if (!empty($additionalWhereSql)) {
|
|
$this->setAdditionalWhereClause($additionalWhereSql);
|
|
}
|
|
}
|
|
// If the forced filters are set, use them
|
|
if ($forcedFilters) {
|
|
$filters = $forcedFilters;
|
|
} else {
|
|
$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 = $this->filter_string_to_array($filters);
|
|
}
|
|
// Make the order an array
|
|
if ($order) {
|
|
$order = explode(':', $order);
|
|
$order = [$order[0] => $order[1]];
|
|
// Make the order direction uppercase
|
|
$order = array_map('strtoupper', $order);
|
|
// Check if the order direction is valid
|
|
if (!in_array($order[array_key_first($order)], ['ASC', 'DESC'])) {
|
|
throw new Exception('Invalid order direction, must be ASC or DESC');
|
|
}
|
|
} else {
|
|
$order = ['id' => 'ASC']; // Default order
|
|
}
|
|
// List the objects with pagination
|
|
if ($page && $limit) {
|
|
return $this->listObjectsWithPagination($page, $limit, $search, $filters, $order, $parseFunction, $join);
|
|
}
|
|
// If the page and limit are not set, list all objects
|
|
return $this->listObjectsWithPagination(1, 1000, $search, $filters, $order, $parseFunction, $join);
|
|
}
|
|
|
|
/**
|
|
* List objects in the table with pagination
|
|
* @param int $page The page number
|
|
* @param int $limit The number of objects per page
|
|
* @return array The list of objects in the table
|
|
* @throws Exception
|
|
*/
|
|
public function listObjectsWithPagination(int $page, int $limit, ?string $search = null, ?array $filters = null, ?array $order = null, $parseFunction = null, array $join = []): array
|
|
{
|
|
global /** @var response $response */
|
|
/** @var db $db */
|
|
$db, $response;
|
|
$mysqli = $db->conn;
|
|
|
|
$offset = ($page - 1) * $limit;
|
|
|
|
// Fetch table fields to search
|
|
$fields = $this->searchableFields;
|
|
$fieldTypes = [];
|
|
$tmpFields = [];
|
|
$result = $mysqli->query("SHOW COLUMNS FROM {$this->table}");
|
|
while ($row = $result->fetch_assoc()) {
|
|
$tmpFields[] = $row['Field'];
|
|
$fieldTypes[$row['Field']] = $row['Type'];
|
|
}
|
|
$result->free();
|
|
|
|
if (empty($fields)) {
|
|
$fields = $tmpFields;
|
|
}
|
|
|
|
$whereClauses = [];
|
|
$params = [];
|
|
$joinClauses = [];
|
|
|
|
// Add where clauses from the object, if set. This is done to allow for custom where clauses in routes, while still allowing for pagination
|
|
if (!empty($this->whereClauses)) {
|
|
$whereClauses = $this->whereClauses;
|
|
}
|
|
|
|
// 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
|
|
// To get objects with one of multiple values for a field, use an array for the value like so:
|
|
// 'status' => ['pending', 'completed']
|
|
if (!empty($filters)) {
|
|
foreach ( $filters as $field => $value ) {
|
|
if (in_array($field, $fields)) {
|
|
// If the value is an array, add multiple filters
|
|
if (is_array($value)) {
|
|
if (empty($value)) {
|
|
$whereClauses[] = '0=1';
|
|
continue;
|
|
}
|
|
$temp = [];
|
|
foreach ( $value as $v ) {
|
|
// Determine the type of the field
|
|
$type = $fieldTypes[$field] ?? '';
|
|
// If the field is an integer, cast the value to an integer
|
|
if (!empty($type) && str_contains($type, 'int')) {
|
|
$temp[] = "`$field` = $v";
|
|
} else {
|
|
$temp[] = "`$field` = ?";
|
|
$params[] = $v;
|
|
}
|
|
//$temp[] = "`$field` = ?";
|
|
//$params[] = $v;
|
|
}
|
|
$whereClauses[] = '(' . implode(' OR ', $temp) . ')';
|
|
continue;
|
|
}
|
|
// If the value is null, add a where clause to check if the field is null
|
|
if ($value === null || (is_string($value) && strtolower($value) === 'null') || (is_string($value) && strtolower($value === 'is_null')) || is_string($value) && strtolower($value === 'is null')) {
|
|
$whereClauses[] = "$field IS NULL";
|
|
continue;
|
|
}
|
|
// If the value is "NOT NULL", add a where clause to check if the field is not null
|
|
if ($value === 'NOT NULL' || (is_string($value) && strtolower($value) === 'not null')) {
|
|
$whereClauses[] = "$field IS NOT NULL";
|
|
continue;
|
|
}
|
|
// If the value is "NOT ZERO", add a where clause to check if the field is not zero
|
|
if ($value === 'NOT ZERO' || (is_string($value) && strtolower($value) === 'not zero')) {
|
|
$whereClauses[] = "$field != 0";
|
|
continue;
|
|
}
|
|
$whereClauses[] = "`$field` = ?";
|
|
$params[] = $value;
|
|
} else {
|
|
// Add support for 'date_from' and 'date_to' filters
|
|
// Example of date_from: 'date_from:2023-01-01'
|
|
// Example of date_to: 'date_to:2023-12-31'
|
|
// In total the filter string would look like: 'created_at-date_from:2023-01-01,created_at-date_to:2023-12-31'
|
|
if (preg_match('/^(.+?)-(date_from|date_to)/', $field, $matches)) {
|
|
$fieldName = $matches[1];
|
|
$filterType = $matches[2];
|
|
$dateValue = $value;
|
|
// If the (date_to|date_from) is E.g. '2023-12-31', then we need to include the entire from/to date
|
|
// If the date is to be inclusive, we need to add 1 day to the date
|
|
if ($filterType === 'date_to') {
|
|
// If the value is null, skip this filter
|
|
if (empty($dateValue)) {
|
|
continue;
|
|
}
|
|
$dateValue = date('Y-m-d', strtotime($dateValue . ' +1 day'));
|
|
}
|
|
|
|
if (in_array($fieldName, $fields)) {
|
|
if ($filterType === 'date_from') {
|
|
if (empty($dateValue)) {
|
|
continue;
|
|
}
|
|
$whereClauses[] = "`$fieldName` >= ?";
|
|
} elseif ($filterType === 'date_to') {
|
|
$whereClauses[] = "`$fieldName` <= ?";
|
|
}
|
|
$params[] = $dateValue;
|
|
}
|
|
} elseif (preg_match('/^(.+?)-has_attribute/', $field, $matches)) {
|
|
// If the filter is e.g. customer_number-has_attribute:invoiceOrdersIndividually
|
|
// Get the field name and the attribute
|
|
$fieldName = $matches[1];
|
|
// Set the attribute to the value, without the (optional) "!" prefix
|
|
$attribute = (string)str_replace('!', '', $value);
|
|
$state = !str_starts_with($value, '!');
|
|
// Check if the field name is valid
|
|
if (!in_array($fieldName, $fields)) {
|
|
throw new Exception('Invalid filter: ' . $field);
|
|
}
|
|
$attributes = array();
|
|
// Push the attribute to the array
|
|
$attributes[] = (string)$attribute;
|
|
// Get the customer numbers with the attribute
|
|
$tmp_customer_numbers_with_attribute = (new users_o())->getCustomerNumbersWithAttributes($attributes);
|
|
// If the state is true, add the customer numbers to the where clause
|
|
if ($state) {
|
|
$whereClauses[] = "$fieldName IN (" . implode(',', array_map('intval', $tmp_customer_numbers_with_attribute)) . ")";
|
|
} else {
|
|
// If the state is false, add the customer numbers to the where clause
|
|
$whereClauses[] = "$fieldName NOT IN (" . implode(',', array_map('intval', $tmp_customer_numbers_with_attribute)) . ")";
|
|
}
|
|
} elseif (preg_match('/^(.+?)-has_key/', $field, $matches)) {
|
|
// If the filter is e.g. customer_number-has_key:invoiceOrdersIndividually
|
|
|
|
// Get the field name and the key
|
|
$fieldName = $matches[1];
|
|
// Set the key to the value, without the (optional) "!" prefix
|
|
$key = (string)str_replace('!', '', $value);
|
|
$state = !str_starts_with($value, '!');
|
|
// Check if the field name is valid
|
|
if (!in_array($fieldName, $fields)) {
|
|
throw new Exception('Invalid filter: ' . $field);
|
|
}
|
|
$attributes = array();
|
|
// Push the attribute to the array
|
|
$attributes[] = (string)$key;
|
|
// Get the customer numbers with the attribute
|
|
$tmp_customer_numbers_with_attribute = (new user_key_value_pairs_o())->getCustomerNumbersWithKey($attributes);
|
|
// If the state is true, add the customer numbers to the where clause
|
|
if ($state) {
|
|
$whereClauses[] = "$fieldName IN (" . implode(',', array_map('intval', $tmp_customer_numbers_with_attribute)) . ")";
|
|
} else {
|
|
// If the state is false, add the customer numbers to the where clause
|
|
$whereClauses[] = "$fieldName NOT IN (" . implode(',', array_map('intval', $tmp_customer_numbers_with_attribute)) . ")";
|
|
}
|
|
} else {
|
|
throw new Exception('Invalid filter: ' . $field);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check for "deleted_at" column
|
|
if (in_array('deleted_at', $fields)) {
|
|
$whereClauses[] = "`deleted_at` IS NULL";
|
|
}
|
|
|
|
|
|
// Add the additional where clause, if set
|
|
if (!empty($this->additionalWhereClause)) {
|
|
$whereClauses[] = $this->additionalWhereClause;
|
|
}
|
|
|
|
// 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;
|
|
|
|
// Join clause with table prefix, so it doesn't conflict with the other columns // This is done to allow for custom join clauses in routes, while still allowing for pagination
|
|
$joinClauses = '';
|
|
if (!empty($join)) {
|
|
// Build the join clauses
|
|
foreach ( $join as $table => $on ) {
|
|
$joinClauses .= " LEFT JOIN `$table` ON $on";
|
|
}
|
|
}
|
|
|
|
// Final query
|
|
$sql = "SELECT * FROM {$this->table} $joinClauses $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, $order);
|
|
|
|
// Parse the objects, if a parse function is provided
|
|
if ($parseFunction) {
|
|
$objects = array_map($parseFunction, $objects);
|
|
}
|
|
|
|
// Clear additional where clause after each pagination query to avoid leaking constraints
|
|
$this->additionalWhereClause = '';
|
|
return $objects;
|
|
}
|
|
|
|
/**
|
|
* List ALL objects in the table (THIS INCLUDES DELETED OBJECTS)
|
|
* @return array The list of objects in the table
|
|
*/
|
|
public function listObjects($parseFunction = null): array
|
|
{
|
|
global /** @var db $db */
|
|
$db;
|
|
$sql = "SELECT * FROM $this->table";
|
|
$result = $db->query($sql);
|
|
$objects = $db->fetch_all($result);
|
|
// Parse the objects, if a parse function is provided
|
|
if ($parseFunction) {
|
|
$objects = array_map($parseFunction, $objects);
|
|
}
|
|
return $objects;
|
|
}
|
|
|
|
/**
|
|
* Forcefully add or override filters to the user's request
|
|
* @param string $filterString The filters to forcefully override/add on the objects (Eg. 'department_id:1,role_id:2')
|
|
* @returns string The updated filters string with the forced filters added
|
|
*/
|
|
public function forceAddFilters(string $filterString): string
|
|
{
|
|
global /** @var response $response */
|
|
$response;
|
|
$filters = $response->getRequestParameter('filters') ?? null; // Get the filters ( Eg. department_id:1,role_id:2 OR department_id:1 )
|
|
// If the filters are set, add the forced filters to the filters
|
|
if ($filters) {
|
|
if (is_array($filters)) {
|
|
$filters = $this->array_to_filters($filters);
|
|
}
|
|
$filters .= ',' . $filterString;
|
|
} else {
|
|
$filters = $filterString;
|
|
}
|
|
return $filters;
|
|
}
|
|
|
|
/**
|
|
* Forcefully restrict filters to the user's request
|
|
* @param array $fieldsAndValues The filters to forcefully restrict on the objects (Eg. ['department_id' => 1, 'role_id' => [1,2]]) - This would throw an error if the user tries to search for a different department_id or role_id than 1 or 2
|
|
* @returns string The updated filters string with the forced filters added (Eg. 'department_id:1,role_id:1,role_id:2')
|
|
* @throws Exception If the user tries to search for a different department_id or role_id than 1 or 2
|
|
*/
|
|
public function forceRestrictFilters(array $fieldsAndValues): string
|
|
{
|
|
global /** @var response $response */
|
|
$response;
|
|
$filters = $response->getRequestParameter('filters') ?? null; // Get the filters ( Eg. department_id:1,role_id:2 OR department_id:1 )
|
|
// If the filters are set, add the forced filters to the filters
|
|
if ($filters) {
|
|
$filters = $this->filter_string_to_array($filters);
|
|
foreach ( $fieldsAndValues as $field => $value ) {
|
|
// If the value is an array, check if the user tries to search for a different value than the ones provided
|
|
if (is_array($value)) {
|
|
// Check if the filter is even set
|
|
if (isset($filters[$field]) && !in_array($filters[$field], $value)) {
|
|
throw new Exception('Invalid filter: ' . $field . ' - ' . $filters[$field] . '. Permitted values: ' . implode(', ', $value));
|
|
}
|
|
continue;
|
|
}
|
|
// If the values can be changed to integers, convert them to integers
|
|
if (is_numeric($value) && is_numeric($filters[$field])) {
|
|
$value = (int)$value;
|
|
$filters[$field] = (int)$filters[$field];
|
|
}
|
|
// Check if the user tries to search for a different value than the one provided
|
|
if ($filters[$field] !== $value) {
|
|
throw new Exception('Invalid filter: ' . $field . ' - ' . $filters[$field] . '. Permitted value: ' . $value);
|
|
}
|
|
}
|
|
// If any of the forced filters are not set, set them - This is done to prevent the user from searching by omitting the forced filters
|
|
foreach ( $fieldsAndValues as $field => $value ) {
|
|
if (!isset($filters[$field])) {
|
|
$filters[$field] = $value;
|
|
}
|
|
}
|
|
} else {
|
|
$filters = $fieldsAndValues;
|
|
}
|
|
return $this->array_to_filters($filters);
|
|
}
|
|
|
|
/**
|
|
* Filter string to array
|
|
* @param string|array $filterString The filter string (Eg. 'department_id:1,role_id:2') or an array of filters
|
|
* @return array The array of filters (Eg. ['department_id' => 1, 'role_id' => 2])
|
|
*/
|
|
public function filter_string_to_array(string|array $filterString): array
|
|
{
|
|
if (is_array($filterString)) {
|
|
$temp = $filterString;
|
|
} else {
|
|
$filters = explode(',', $filterString);
|
|
$temp = [];
|
|
foreach ( $filters as $filter ) {
|
|
$filter = explode(':', $filter);
|
|
if (count($filter) < 2) {
|
|
continue;
|
|
}
|
|
// Check if the filter already exists, if it does, make it an array
|
|
if (isset($temp[$filter[0]])) {
|
|
if (!is_array($temp[$filter[0]])) {
|
|
$temp[$filter[0]] = [$temp[$filter[0]]];
|
|
}
|
|
// Check if the filter already exists within the array with the same key, if it does, skip it
|
|
if (in_array($filter[1], $temp[$filter[0]])) {
|
|
continue;
|
|
}
|
|
$temp[$filter[0]][] = $filter[1];
|
|
continue;
|
|
}
|
|
$temp[$filter[0]] = $filter[1];
|
|
}
|
|
}
|
|
|
|
// Check if the value is "null", if it is, set it to null
|
|
foreach ( $temp as $key => $value ) {
|
|
if (is_string($value) && strtolower($value) === 'null') {
|
|
$temp[$key] = null;
|
|
}
|
|
}
|
|
return $temp;
|
|
}
|
|
|
|
/**
|
|
* Convert an array of filters to a string
|
|
* @param $array array The array of filters
|
|
* @return string
|
|
* @example ['department_id' => [1,2], 'role_id' => 2, 'id' => 1] => 'department_id:1,department_id:2,role_id:2,id:1'
|
|
*/
|
|
public function array_to_filters(array $array): string
|
|
{
|
|
$filter_string = '';
|
|
foreach ( $array as $key => $value ) {
|
|
// If this is not the first filter, add a comma to separate the filters
|
|
if ($filter_string !== '') {
|
|
$filter_string .= ',';
|
|
}
|
|
// If the value is an array, add multiple filters
|
|
if (is_array($value)) {
|
|
foreach ( $value as $v ) {
|
|
$filter_string .= $key . ':' . $v . ',';
|
|
}
|
|
// Remove the last comma
|
|
$filter_string = rtrim($filter_string, ',');
|
|
continue;
|
|
}
|
|
// Add the filter
|
|
$filter_string .= $key . ':' . $value;
|
|
}
|
|
return $filter_string;
|
|
}
|
|
|
|
/**
|
|
* 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'];
|
|
}
|
|
|
|
/**
|
|
* Get the table of the objects in the database
|
|
* @returns string
|
|
*/
|
|
public function getTable(): string
|
|
{
|
|
return $this->table;
|
|
}
|
|
|
|
/**
|
|
* Set the table of the objects in the database
|
|
* @param string $table The table of the objects in the database
|
|
*/
|
|
public function setTable(string $table): void
|
|
{
|
|
// Add the backticks to the table name, to prevent SQL injection and reserved keyword issues.
|
|
$table = "`$table`";
|
|
$this->table = $table;
|
|
}
|
|
|
|
/**
|
|
* Cache object
|
|
* @param string $key The key to cache the object
|
|
* @param mixed $data The data to cache
|
|
*/
|
|
public function cache(string $key, mixed $data, $objectId = null): void
|
|
{
|
|
// If the object id is not set, use the object id
|
|
if (!$objectId) {
|
|
$objectId = $this->id;
|
|
}
|
|
// if the data is an array, object, or resource, convert it to a string
|
|
if (is_array($data) || is_object($data) || is_resource($data)) {
|
|
$data = json_encode($data);
|
|
}
|
|
// Cache the data
|
|
redis->set($this->table . '_' . $objectId . '_' . $key, $data);
|
|
}
|
|
|
|
/**
|
|
* Get cached values for multiple objects
|
|
* @param string $key The key to get the cached object
|
|
* @param array $objectIds The object ids to get the cached objects for
|
|
* @return array The cached object keys
|
|
*/
|
|
public function getCachedForMultipleObjects(string $key, array $objectIds): array
|
|
{
|
|
return redis->mget(array_map(function($objectId) use ($key) {
|
|
return $this->table . '_' . $objectId . '_' . $key;
|
|
}, $objectIds));
|
|
}
|
|
|
|
/**
|
|
* Set cached object expiration time
|
|
* @param string $key The key to set the cached object expiration time
|
|
* @param int $seconds The number of seconds to set the cached object expiration time
|
|
* @param null $objectId
|
|
* @return void
|
|
* @throws Exception If the object is not selected, and no object id is provided, it throws an exception
|
|
*/
|
|
public function setCachedExpiration(string $key, int $seconds, $objectId = null): void
|
|
{
|
|
// If the object id is not set, use the object id
|
|
if (!$objectId) {
|
|
$this->requireSelected();
|
|
$objectId = $this->id;
|
|
}
|
|
// Set the expiration time for the cached data
|
|
redis->expire($this->table . '_' . $objectId . '_' . $key, $seconds);
|
|
}
|
|
|
|
/**
|
|
* Get a cached object key (redis key)
|
|
* @param string $key The key to get the cached object
|
|
* @return string The cached object key
|
|
* @throws Exception If the object is not selected, it throws an exception
|
|
*/
|
|
public function getCachedKey(string $key, $objectId = null): string
|
|
{
|
|
// If the object id is not set, use the object id
|
|
if (!$objectId) {
|
|
self::requireSelected();
|
|
$objectId = $this->id;
|
|
}
|
|
// Return the cached data key
|
|
return $this->table . '_' . $objectId . '_' . $key;
|
|
}
|
|
|
|
/**
|
|
* Get cached object
|
|
* @param string $key The key to get the cached object
|
|
* @return mixed The cached object
|
|
*/
|
|
public function getCached(string $key, $objectId = null): mixed
|
|
{
|
|
// If the object id is not set, use the object id
|
|
if (!$objectId) {
|
|
$objectId = $this->id;
|
|
}
|
|
// Get the cached data
|
|
$data = redis->get($this->table . '_' . $objectId . '_' . $key) ?? null;
|
|
// if the data is a JSON string, convert it to an array
|
|
if (is_string($data) && json_decode($data)) {
|
|
$data = json_decode($data);
|
|
}
|
|
return $data;
|
|
}
|
|
|
|
/**
|
|
* Method cache with parameters
|
|
* @param string $method The method to cache the result of
|
|
* @param array $params The parameters to pass to the method
|
|
* @param int $expiration The expiration time in seconds
|
|
* @param callable|null $function The function to call if the cached result is not found, it should return the result to be cached
|
|
* @return mixed The cached result of the method
|
|
*/
|
|
public function methodCacheWithParameters(string $method, array $params = [], int $expiration = 120, callable $function = null): mixed
|
|
{
|
|
// Generate the cache key
|
|
$objectId = "methodCacheWithParameters";
|
|
$key = $method . '_' . md5(serialize($params));
|
|
// Try to get the cached result
|
|
$cachedResult = $this->getCached($key, $objectId);
|
|
// If the cached result is found, return it
|
|
if ($cachedResult !== null) {
|
|
return $cachedResult;
|
|
}
|
|
// If the cached result is not found, call the function to get the result
|
|
if ($function) {
|
|
$result = call_user_func_array($function, $params);
|
|
} else {
|
|
// If no function is provided, call the method on this object
|
|
$result = call_user_func_array([$this, $method], $params);
|
|
}
|
|
// Cache the result
|
|
$this->cache($key, $result, $objectId);
|
|
// Set the expiration time
|
|
$this->setCachedExpiration($key, $expiration, $objectId);
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* Delete cached object
|
|
* @param string $key The key to delete the cached object
|
|
*/
|
|
public function deleteCached(string $key, $objectId = null): void
|
|
{
|
|
// If the object id is not set, use the object id
|
|
if (!$objectId) {
|
|
$objectId = $this->id;
|
|
}
|
|
// Delete the cached data
|
|
redis->delete($this->table . '_' . $objectId . '_' . $key);
|
|
}
|
|
|
|
/**
|
|
* Delete the object from the database, or if the deleted_at column exists, soft delete the object.
|
|
* @throws Exception If the object is not selected, it throws an exception
|
|
*/
|
|
public function delete(): void
|
|
{
|
|
self::requireSelected();
|
|
// Check if the deleted_at column exists
|
|
if (self::columnsExist(['deleted_at'])) {
|
|
// Soft delete the object
|
|
self::update(['deleted_at' => date('Y-m-d H:i:s')]);
|
|
} else {
|
|
// Delete the object from the database
|
|
self::delete_object($this->table, $this->id);
|
|
}
|
|
$this->markSystemSearchDirtyTable();
|
|
// Trigger the object changed event
|
|
self::objectChanged();
|
|
}
|
|
|
|
/**
|
|
* Columns exist
|
|
* Check if all the columns in the array exist in the table
|
|
* @param array $columns The columns to check if they exist in the table E.g. ['name', 'email']
|
|
* @return bool True if all the columns exist in the table, false otherwise
|
|
*/
|
|
public function columnsExist(array $columns): bool
|
|
{
|
|
global $db;
|
|
$table = $this->table;
|
|
$result = $db->query("SHOW COLUMNS FROM $table");
|
|
$fields = $db->fetch_all($result);
|
|
$fields = array_column($fields, 'Field');
|
|
return count(array_intersect($columns, $fields)) === count($columns);
|
|
}
|
|
|
|
/**
|
|
* Update object
|
|
* @param array $data The data to update the object with (E.g. ['name' => 'John', 'email' => 'email@example.com'])
|
|
* @throws Exception If the object is not selected, it throws an exception
|
|
*/
|
|
public function update(array $data): void
|
|
{
|
|
self::requireSelected();
|
|
// Update the object in the database
|
|
global $db;
|
|
$set = [];
|
|
foreach ( $data as $key => $value ) {
|
|
// JSON encode objects and arrays
|
|
if (is_object($value) || is_array($value)) {
|
|
$value = json_encode($value, JSON_UNESCAPED_UNICODE);
|
|
if ($value === false) {
|
|
throw new Exception('Failed to encode value for key: ' . $key . ' - ' . json_last_error_msg());
|
|
}
|
|
}
|
|
// If the value is null, set it to null
|
|
if ($value === null || (is_string($value) && strtolower($value) === 'null')) {
|
|
$set[] = "$key = NULL";
|
|
continue;
|
|
}
|
|
|
|
// If the value is boolean, convert it to 1 or 0
|
|
if (is_bool($value)) {
|
|
$value = $value ? 1 : 0;
|
|
}
|
|
|
|
$value = $db->escape_string($value);
|
|
$set[] = "$key = '$value'";
|
|
}
|
|
// Loop through all the keys, and find the property for each key, validate that it's class is object_property, and if the value is the same as the current value of the property, remove it from the set array to prevent unnecessary updates and triggering of the object changed event
|
|
foreach ( $data as $key => $value ) {
|
|
/**
|
|
* @var object_property $property
|
|
*/
|
|
if (property_exists($this, $key) && $this->$key instanceof object_property) {
|
|
$currentValue = $this->$key->value();
|
|
// If the value is the same as the current value of the property, remove it from the set array to prevent unnecessary updates and triggering of the object changed event
|
|
if ($currentValue == $value) { // Remove the key from the set array
|
|
foreach ( $set as $index => $setValue ) {
|
|
if (str_starts_with($setValue, "$key =")) {
|
|
unset($set[$index]);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
// Update the value of the property
|
|
$this->$key->set($value);
|
|
}
|
|
}
|
|
if (empty($set)) {
|
|
return;
|
|
}
|
|
$this->markSystemSearchDirtyTable();
|
|
// Trigger the object changed event
|
|
self::objectChanged();
|
|
}
|
|
|
|
/**
|
|
* generateFakeId - This function generates a fake id for the object
|
|
* @see orders_o::simulateOrderFromXLVask()
|
|
* @returns int A fake (negative) id for the object
|
|
*/
|
|
public function generateFakeId(): int
|
|
{
|
|
global /** @var db $db */
|
|
$db;
|
|
return $db->getNextFakeId($this->table);
|
|
}
|
|
|
|
/**
|
|
* Object changed
|
|
* This function should be called after an object property is changed
|
|
* It will trigger an event to notify the object has changed
|
|
* @return void
|
|
*/
|
|
abstract public function objectChanged(): void;
|
|
|
|
/**
|
|
* Delete object by table and id (Permanently, no soft delete)
|
|
* @param string $table The table of the object in the database
|
|
* @param int $id The id of the object in the database
|
|
*/
|
|
public static function delete_object(string $table, int $id): void
|
|
{
|
|
global $db;
|
|
$sql = "DELETE FROM $table WHERE id = $id";
|
|
$db->query($sql);
|
|
try {
|
|
system_search_cache::markDirtyTable(trim((string)$table, " `\t\n\r\0\x0B"));
|
|
} catch (Throwable) {
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete object permanently
|
|
* @throws Exception If the object is not selected, it throws an exception
|
|
*/
|
|
public function deletePermanently(): void
|
|
{
|
|
self::requireSelected();
|
|
// Delete the object from the database
|
|
self::delete_object($this->table, $this->id);
|
|
$this->markSystemSearchDirtyTable();
|
|
// Trigger the object changed event
|
|
self::objectChanged();
|
|
}
|
|
|
|
/**
|
|
* Get all objects fields
|
|
* @param array $fields The fields to get from the objects (Eg. ['name', 'description'])
|
|
* @return array The list of fields from the objects (Eg. [['name' => 'John', 'description' => 'Doe'], ['name' => 'Jane', 'description' => 'Doe']])
|
|
*/
|
|
public function getFields(array $fields): array
|
|
{
|
|
global $db;
|
|
$sql = "SELECT " . implode(', ', $fields) . " FROM $this->table";
|
|
$result = $db->query($sql);
|
|
return $db->fetch_all($result);
|
|
}
|
|
|
|
/**
|
|
* Select an object by id
|
|
* @param int $id The id of the object in the database
|
|
* @returns $this
|
|
* @throws Exception If the object does not exist in the database, it throws an exception
|
|
*/
|
|
public function select(int $id): self
|
|
{
|
|
// Check if the object exists in the database
|
|
$this->id = $id;
|
|
$this->getObjectProperties();
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Set the view, used on the pagination
|
|
* @param string $view The view to set
|
|
* @return users_o|bookings_new_o|bookings_o|branding_o|categories_o|collected_order_invoices_o|cron_o|currency_conversion_rates_o|customer_codes_o|customer_notes_o|customer_vehicles_o|department_categories_o|department_daily_reports_o|department_variables_o|departments_o|economic_module_orders|form_submissions_o|fxratesapi_conversion_rates_o|groups_o|groups_permissions_o|logs_o|module_action_logs_o|motorapi_lookups_o|notifications_o|order_items_o|orders_o|plate_scanners_o|plate_scans_o|product_options_o|products_o|ratelimit_o|stripe_module_customers_o|stripe_module_orders_o|tokens_o|user_key_value_pairs_o|user_price_overrides_o|db_object_t
|
|
*/
|
|
public function setView(string $view): self
|
|
{
|
|
$this->table = $view;
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Add an object to the database, using the data provided
|
|
* @param array $data The data to add the object with (E.g. ['name' => 'John', 'email' => 'email@example.com'])
|
|
* @return int The id of the new object
|
|
* @throws Exception If the creation of the object fails, it throws an exception
|
|
*/
|
|
public function add_object(array $data): int
|
|
{
|
|
global /** @var db $db */
|
|
$db;
|
|
try {
|
|
// Update the object in the database
|
|
$set = [];
|
|
foreach ( $data as $key => $value ) {
|
|
// JSON encode objects and arrays
|
|
if (is_object($value) || is_array($value)) {
|
|
$value = json_encode($value, JSON_UNESCAPED_UNICODE);
|
|
if ($value === false) {
|
|
throw new Exception('Failed to encode value for key: ' . $key . ' - ' . json_last_error_msg());
|
|
}
|
|
$set[] = "$key = '$value'";
|
|
continue;
|
|
}
|
|
// If the value is null, set it to null
|
|
elseif ($value === null || (is_string($value) && strtolower($value) === 'null')) {
|
|
$value = null;
|
|
$set[] = "$key = NULL";
|
|
continue;
|
|
}
|
|
// If the value is numeric, don't escape it
|
|
elseif (is_numeric($value) && strlen((int)$value) == strlen($value)) {
|
|
$value = (int)$value;
|
|
$set[] = "$key = $value";
|
|
continue;
|
|
}
|
|
|
|
// If the value is boolean, convert it to 1 or 0
|
|
elseif (is_bool($value)) {
|
|
$value = $value ? 1 : 0;
|
|
$set[] = "$key = $value";
|
|
continue;
|
|
} else {
|
|
// If the value is a string, escape it
|
|
$value = $db->escape_string($value);
|
|
$set[] = "$key = '$value'";
|
|
}
|
|
}
|
|
$set = implode(', ', $set);
|
|
$sql = "INSERT INTO $this->table SET $set";
|
|
$db->query($sql);
|
|
$this->markSystemSearchDirtyTable();
|
|
return $db->insert_id();
|
|
} catch (Exception $e) {
|
|
throw new Exception($e->getMessage());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Does this object exist in the database?
|
|
* @return bool True if the object exists in the database, false otherwise
|
|
*/
|
|
public function exists(): bool
|
|
{
|
|
// Check if the id is set
|
|
if (!isset($this->id)) {
|
|
return false;
|
|
}
|
|
// Check if the object exists in the database
|
|
global $db;
|
|
$id = $this->id;
|
|
$sql = "SELECT * FROM $this->table WHERE id = $id";
|
|
$result = $db->query($sql);
|
|
// Check if the object exists, and if there's a deleted_at column, check if the object is not deleted
|
|
return $db->num_rows($result) > 0 && (!$this->columnsExist(['deleted_at']) || $db->fetch_assoc($result)['deleted_at'] === null);
|
|
}
|
|
|
|
/**
|
|
* Restore the object from the database, if the deleted_at column exists.
|
|
* @throws Exception If the object is not selected, it throws an exception
|
|
*/
|
|
public function restore(): void
|
|
{
|
|
self::requireSelected();
|
|
// Check if the deleted_at column exists
|
|
if (self::columnsExist(['deleted_at'])) {
|
|
// Restore the object
|
|
global $db;
|
|
$id = $this->id;
|
|
$sql = "UPDATE $this->table SET deleted_at = NULL WHERE id = $id";
|
|
$db->query($sql);
|
|
$this->markSystemSearchDirtyTable();
|
|
// Trigger the object changed event
|
|
self::objectChanged();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Add an attachment to the (current) object
|
|
* @param attachment_content $content The content of the attachment
|
|
* @return object_attachments_o The attachment object
|
|
* @throws Exception If the object is not selected, it throws an exception
|
|
*/
|
|
public function addAttachment(attachment_content $content): object_attachments_o
|
|
{
|
|
self::requireSelected();
|
|
// Add the attachment to the object
|
|
$attachment = new attachments();
|
|
$id = $attachment->create($this->table, $this->id, $content);
|
|
return (new object_attachments_o())->select($id);
|
|
}
|
|
/**
|
|
* List attachments of the (current) object
|
|
* @return attachment[] The list of attachments of the object
|
|
* @throws Exception If the object is not selected, it throws an exception
|
|
*/
|
|
public function listAttachments(): array
|
|
{
|
|
self::requireSelected();
|
|
// List the attachments of the object
|
|
$attachment = new attachments();
|
|
return $attachment->list($this->table, $this->id);
|
|
}
|
|
/**
|
|
* Remove an attachment
|
|
* @param int $attachmentId The id of the attachment to remove
|
|
* @throws Exception If the object is not selected, it throws an exception
|
|
*/
|
|
public function removeAttachment(int $attachmentId): void
|
|
{
|
|
self::requireSelected();
|
|
// Remove the attachment from the object
|
|
$attachment = new attachments();
|
|
$attachment->delete($attachmentId);
|
|
}
|
|
|
|
/**
|
|
* Get an attachment
|
|
* @param int $attachmentId The id of the attachment to get
|
|
* @return object_attachments_o The attachment object
|
|
* @throws Exception If the object is not selected, it throws an exception
|
|
* @throws Exception If the attachment does not exist, it throws an exception
|
|
*/
|
|
public function getAttachment(int $attachmentId): object_attachments_o
|
|
{
|
|
self::requireSelected();
|
|
// Get the attachment from the object
|
|
$attachment = new attachments();
|
|
return $attachment->get($attachmentId);
|
|
}
|
|
}
|