['*'], 'where' => '', 'from' => '', 'leftJoin' => '', 'rightJoin' => '', 'innerJoin' => '', 'crossJoin' => '', 'orderBy' => '', 'groupBy' => '', 'limit' => '', ]; /** * Retrieves the database instance. * * @return PDO */ protected static function db(): PDO { if (DB_TYPE == 'sqlite') { return Database::getInstance( type: DB_TYPE, name: DB_NAME ); } else { return Database::getInstance( DB_TYPE, DB_HOST, DB_NAME, DB_USER, DB_PASS ); } } /** * Executes PDO::beginTransaction to start a transaction. * More info: https://www.php.net/manual/es/pdo.begintransaction.php (Spanish, will keep for context) * * @return bool */ public static function beginTransaction(): bool { return static::db()->beginTransaction(); } /** * Executes PDO::rollBack to undo changes in a transaction. * More info: https://www.php.net/manual/es/pdo.rollback.php (Spanish, will keep for context) * * @return bool */ public static function rollBack(): bool { if (static::db()->inTransaction()) { return static::db()->rollBack(); } else { return true; } } /** * Executes PDO::commit to commit a transaction. * More info: https://www.php.net/manual/es/pdo.commit.php (Spanish, will keep for context) * * @return bool */ public static function commit(): bool { if (static::db()->inTransaction()) { return static::db()->commit(); } else { return true; } } /** * Executes an SQL statement in the database. * * @param string $query * Contains the SQL statement to be executed. * * @throws Exception * If the SQL statement fails, it will throw an error * and perform a rollback if currently within a transaction * (see beginTransaction method). * * @param bool $resetQuery * Indicates whether the query should be reset (defaults to true). * * @return array * Contains the result of the SQL call. */ protected static function query(string $query, bool $resetQuery = true): array { $db = static::db(); try { $prepared = $db->prepare($query); $prepared->execute(static::$queryVars); } catch (PDOException $e) { if ($db->inTransaction()) { $db->rollBack(); } $vars = json_encode(static::$queryVars); throw new Exception( "\nError at query to database.\n" . "Query: $query\n" . "Vars: $vars\n" . "Error:\n" . $e->getMessage() ); } $result = $prepared->fetchAll(); if ($resetQuery) { static::resetQuery(); } return $result; } /** * Resets the SQL query configuration. * @return void */ protected static function resetQuery(): void { static::$querySelect = [ 'select' => ['*'], 'where' => '', 'from' => '', 'leftJoin' => '', 'rightJoin' => '', 'innerJoin' => '', 'crossJoin' => '', 'orderBy' => '', 'groupBy' => '', 'limit' => '', ]; static::$queryVars = []; } /** * Builds the SQL statement from static::$querySelect and, once * built, calls resetQuery. * * @return string * Contains the SQL statement. */ protected static function buildQuery(): string { $sql = 'SELECT ' . join(', ', static::$querySelect['select']); if (static::$querySelect['from'] != '') { $sql .= ' FROM ' . static::$querySelect['from']; } else { $sql .= ' FROM ' . static::table(); } if (static::$querySelect['crossJoin'] != '') { $sql .= static::$querySelect['crossJoin']; } if (static::$querySelect['innerJoin'] != '') { $sql .= static::$querySelect['innerJoin']; } if (static::$querySelect['leftJoin'] != '') { $sql .= static::$querySelect['leftJoin']; } if (static::$querySelect['rightJoin'] != '') { $sql .= static::$querySelect['rightJoin']; } if (static::$querySelect['where'] != '') { $sql .= ' WHERE ' . static::$querySelect['where']; } if (static::$querySelect['groupBy'] != '') { $sql .= ' GROUP BY ' . static::$querySelect['groupBy']; } if (static::$querySelect['orderBy'] != '') { $sql .= ' ORDER BY ' . static::$querySelect['orderBy']; } if (static::$querySelect['limit'] != '') { $sql .= ' LIMIT ' . static::$querySelect['limit']; } return $sql; } /** * Configures $queryVars to bind a value to a * substitution parameter and returns the latter. * * @param string $value * Value to bind. * * @return string * Substitution parameter. */ public static function bind(string $value): string { $index = ':v_' . count(static::$queryVars); static::$queryVars[$index] = $value; return $index; } /** * Creates an instance of the current object from an array. * * @param mixed $elem * Can receive an array or an object containing the values * that its attributes will have. * * @return static * Returns an object of the current class. */ protected static function getInstance(array $elem = []): static { $class = get_called_class(); $instance = new $class(); $reflection = new ReflectionClass($instance); $properties = $reflection->getProperties(); $propertyNames = array_map(function ($property) { return static::camelCaseToSnakeCase($property->name); }, $properties); foreach ($elem as $key => $value) { $index = array_search($key, $propertyNames); if (is_numeric($index)) { if (enum_exists($properties[$index]->getType()->getName())) { $instance->{$properties[$index]->name} = $properties[$index]->getType()->getName()::tryfrom($value); } else { $instance->{$properties[$index]->name} = $value; } } else { $instance->{static::snakeCaseToCamelCase($key)} = $value; } } return $instance; } /** * Returns the attributes to be saved for the current class. * Attributes will be those that are public and not excluded in static::$ignoreSave, * and those that are private or protected but are in static::$forceSave. * * @return array * Contains the indexed attributes of the current object. */ protected function getVars(): array { $reflection = new ReflectionClass($this); $properties = $reflection->getProperties(ReflectionProperty::IS_PUBLIC); $result = []; foreach ($properties as $property) { if (!in_array($property->name, static::$ignoreSave)) { $result[$this->camelCaseToSnakeCase($property->name)] = isset($this->{$property->name}) ? $this->{$property->name} : null; } } foreach (static::$forceSave as $value) { $result[$value] = isset($this->$value) ? $this->$value : null; } foreach ($result as $i => $property) { if (gettype($property) == 'boolean') { $result[$i] = $property ? '1' : '0'; } if ($property instanceof \UnitEnum) { $result[$i] = $property->value ?? $property->name; } } return $result; } /** * Returns the name of the current class, even if it's an extended class. * * @return string * */ public static function className(): string { return substr( strrchr(get_called_class(), '\\'), 1 ); } /** * Constructs (from the class name and the suffix in static::$tableSufix) * and/or returns the name of the DB table where the current object will be * or is stored. * * @return string */ protected static function table(): string { if (isset(static::$table)) { return static::$table; } return static::camelCaseToSnakeCase(static::className()) . 's'; } /** * Converts from lowerCamelCase to snake_case. * * @param string $string * * @return string */ protected static function camelCaseToSnakeCase(string $string): string { return strtolower( preg_replace( '/(?getVars(); foreach ($atts as $key => $value) { if (isset($value)) { if (in_array($key, $this->toNull)) { $set[] = "$key=NULL"; } else { $set[] = "$key=:$key"; static::$queryVars[':' . $key] = $value; } } else { if (in_array($key, $this->toNull)) { $set[] = "$key=NULL"; } } } $table = static::table(); $pk = static::$primaryKey; $pkv = $this->$pk; $sql = "UPDATE $table SET " . join(', ', $set) . " WHERE $pk='$pkv'"; static::query($sql); } /** * Inserts a new row into the database from the * current object. * @return void */ protected function add(): void { $db = static::db(); $atts = $this->getVars(); $into = []; $values = []; foreach ($atts as $key => $value) { if (isset($value)) { $into[] = "$key"; $values[] = ":$key"; static::$queryVars[":$key"] = $value; } } $table = static::table(); $sql = "INSERT INTO $table (" . join(', ', $into) . ") VALUES (" . join(', ', $values) . ")"; static::query($sql); $pk = static::$primaryKey; $this->$pk = $db->lastInsertId(); } /** * Checks if the object to be saved is new or not, and based on the result, * calls `update` to update an existing row or `add` to insert a new row. * @return void */ public function save(): void { $pk = static::$primaryKey; if (isset($this->$pk)) { $this->update(); } else { $this->add(); } } /** * Deletes the current object from the database. * @return void */ public function delete(): void { $table = static::table(); $pk = static::$primaryKey; $sql = "DELETE FROM $table WHERE $pk=:$pk"; static::$queryVars[":$pk"] = $this->$pk; static::query($sql); } /** * Defines SELECT in the SQL statement. * * @param array $columns * Columns to be selected in the SQL query. * * @return static */ public static function select(string ...$columns): static { static::$querySelect['select'] = $columns; return new static(); } /** * Defines FROM in the SQL statement. * * @param array $tables * Tables to be selected in the SQL query. * * @return static */ public static function from(...$tables): static { static::$querySelect['from'] = join(', ', $tables); return new static(); } /** * Defines the WHERE clause in the SQL statement. * * @param string $column * The column to compare. * * @param string $operatorOrValue * The operator or the value to compare as equal if $value is not defined. * * @param string|null $value * (Optional) The value to compare against the column. * * @param bool $no_filter * (Optional) Used when $value is a column or a value that does not require * filtering against SQL injection attacks (defaults to false). * * @return static */ public static function where( string $column, string $operatorOrValue, ?string $value = null, bool $no_filter = false ): static { return static::and( $column, $operatorOrValue, $value, $no_filter ); } /** * Defines AND in the SQL statement (can be nested). * * @param string $column * The column to compare. * * @param string $operatorOrValue * The operator or the value to compare as equal if $value is not defined. * * @param string|null $value * (Optional) The value to compare against the column. * * @param bool $no_filter * (Optional) Used when $value is a column or a value that does not require * filtering against SQL injection attacks (defaults to false). * * @return static */ public static function and( string $column, string $operatorOrValue, ?string $value = null, bool $no_filter = false ): static { if (is_null($value)) { $value = $operatorOrValue; $operatorOrValue = '='; } if (!$no_filter) { $value = static::bind($value); } if (static::$querySelect['where'] == '') { static::$querySelect['where'] = "$column $operatorOrValue $value"; } else { static::$querySelect['where'] .= " AND $column $operatorOrValue $value"; } return new static(); } /** * Defines OR in the SQL statement (can be nested). * * @param string $column * The column to compare. * * @param string $operatorOrValue * The operator or the value to compare as equal if $value is not defined. * * @param string|null $value * (Optional) The value to compare against the column. * * @param bool $no_filter * (Optional) Used when $value is a column or a value that does not require * filtering against SQL injection attacks (defaults to false). * * @return static */ public static function or( string $column, string $operatorOrValue, ?string $value = null, bool $no_filter = false ): static { if (is_null($value)) { $value = $operatorOrValue; $operatorOrValue = '='; } if (!$no_filter) { $value = static::bind($value); } if (static::$querySelect['where'] == '') { static::$querySelect['where'] = "$column $operatorOrValue $value"; } else { static::$querySelect['where'] .= " OR $column $operatorOrValue $value"; } return new static(); } /** * Defines WHERE using IN in the SQL statement. * * @param string $column * The column to compare. * * @param array $arr * Array with all values to compare against the column. * * @return static */ public static function whereIn( string $column, array $arr, ): static { $arrIn = []; foreach ($arr as $value) { $arrIn[] = static::bind($value); } $where = "$column IN (" . join(', ', $arrIn) . ")"; if (static::$querySelect['where'] == '') { static::$querySelect['where'] = $where; } else { static::$querySelect['where'] .= " AND $where"; } return new static(); } /** * Defines WHERE using NOT IN in the SQL statement. * * @param string $column * The column to compare. * * @param array $arr * Array with all values to compare against the column. * * @return static */ public static function whereNotIn( string $column, array $arr, ): static { $arrIn = []; foreach ($arr as $value) { $arrIn[] = static::bind($value); } $where = "$column NOT IN (" . join(', ', $arrIn) . ")"; if (static::$querySelect['where'] == '') { static::$querySelect['where'] = $where; } else { static::$querySelect['where'] .= " AND $where"; } return new static(); } /** * Defines WHERE using IS NULL in the SQL statement. * * @param string $column * The column to compare. * * @return static */ public static function whereNull(string $column): static { static::where($column, 'IS', 'NULL', true); return new static(); } /** * Defines WHERE using IS NOT NULL in the SQL statement. * * @param string $column * The column to compare. * * @return static */ public static function whereNotNull(string $column): static { static::where($column, 'IS NOT', 'NULL', true); return new static(); } /** * Defines WHERE using EXISTS in the SQL statement. * * @param string $query * SQL query. * * @return static */ public static function whereExists(string $query): static { static::where('', 'EXISTS', "($query)", true); return new static(); } /** * Defines WHERE using NOT EXISTS in the SQL statement. * * @param string $query * SQL query. * * @return static */ public static function whereNotExists(string $query): static { static::where('', 'NOT EXISTS', "($query)", true); return new static(); } /** * Defines LEFT JOIN in the SQL statement. * * @param string $table * Table to join with the current object's table. * * @param string $columnA * Column to compare for the join. * * @param string $operatorOrColumnB * Operator or column to compare as equal for the join * if $columnB is not defined. * * @param string|null $columnB * (Optional) Column to compare for the join. * * @return static */ public static function leftJoin( string $table, string $columnA, string $operatorOrColumnB, ?string $columnB = null ): static { if (is_null($columnB)) { $columnB = $operatorOrColumnB; $operatorOrColumnB = '='; } static::$querySelect['leftJoin'] .= ' LEFT JOIN ' . $table . ' ON ' . "$columnA$operatorOrColumnB$columnB"; return new static(); } /** * Defines RIGHT JOIN in the SQL statement. * * @param string $table * Table to join with the current object's table. * * @param string $columnA * Column to compare for the join. * * @param string $operatorOrColumnB * Operator or column to compare as equal for the join * if $columnB is not defined. * * @param string|null $columnB * (Optional) Column to compare for the join. * * @return static */ public static function rightJoin( string $table, string $columnA, string $operatorOrColumnB, ?string $columnB = null ): static { if (is_null($columnB)) { $columnB = $operatorOrColumnB; $operatorOrColumnB = '='; } if (static::db()->getAttribute(PDO::ATTR_DRIVER_NAME) == 'sqlite') { $currentTable = empty(static::$querySelect['from']) ? static::table() : static::$querySelect['from']; static::$querySelect['from'] = $table; return static::leftJoin($currentTable, $columnB, $operatorOrColumnB, $columnA); } static::$querySelect['rightJoin'] .= ' RIGHT JOIN ' . $table . ' ON ' . "$columnA$operatorOrColumnB$columnB"; return new static(); } /** * Defines INNER JOIN in the SQL statement. * * @param string $table * Table to join with the current object's table. * * @param string $columnA * Column to compare for the join. * * @param string $operatorOrColumnB * Operator or column to compare as equal for the join * if $columnB is not defined. * * @param string|null $columnB * (Optional) Column to compare for the join. * * @return static */ public static function innerJoin( string $table, string $columnA, string $operatorOrColumnB, ?string $columnB = null ): static { if (is_null($columnB)) { $columnB = $operatorOrColumnB; $operatorOrColumnB = '='; } static::$querySelect['innerJoin'] .= ' INNER JOIN ' . $table . ' ON ' . "$columnA$operatorOrColumnB$columnB"; return new static(); } /** * Defines CROSS JOIN in the SQL statement. * * @param string $table * Table to join with the current object's table. * * @param string $columnA * Column to compare for the join. * * @param string $operatorOrColumnB * Operator or column to compare as equal for the join * if $columnB is not defined. * * @param string|null $columnB * (Optional) Column to compare for the join. * * @return static */ public static function crossJoin( string $table, ): static { static::$querySelect['crossJoin'] .= ' CROSS JOIN ' . $table; return new static(); } /** * Defines GROUP BY in the SQL statement. * * @param array $columns * Columns to group by. * * @return static */ public static function groupBy(string ...$columns): static { static::$querySelect['groupBy'] = join(', ', $columns); return new static(); } /** * Defines LIMIT in the SQL statement. * * @param int $offsetOrQuantity * Defines the rows to skip or the quantity to take * if $quantity is not defined. * @param int|null $quantity * (Optional) Defines the maximum number of rows to take. * * @return static */ public static function limit(int $offsetOrQuantity, ?int $quantity = null): static { if (is_null($quantity)) { static::$querySelect['limit'] = $offsetOrQuantity; } else { static::$querySelect['limit'] = $quantity . ' OFFSET ' . $offsetOrQuantity; } return new static(); } /** * Defines ORDER BY in the SQL statement. * * @param string $value * Column to order by. * * @param string $order * (Optional) Defines whether the order will be ascending (ASC), * descending (DESC), or random (RAND). * * @return static */ public static function orderBy(string $value, string $order = 'ASC'): static { if ($value == "RAND") { static::$querySelect['orderBy'] = 'RAND()'; return new static(); } if (!(strtoupper($order) == 'ASC' || strtoupper($order) == 'DESC')) { $order = 'ASC'; } static::$querySelect['orderBy'] = $value . ' ' . $order; return new static(); } /** * Returns the number of rows in a query. * * @param bool $resetQuery * (Optional) Indicates whether the query should be reset (defaults to true). * * @param bool $useLimit * (Optional) Allows using limit to establish an initial and final maximum for counting. * Requires the limit to have been defined beforehand (defaults to false). * * @return int */ public static function count(bool $resetQuery = true, bool $useLimit = false): int { if (!$resetQuery) { $backup = [ 'select' => static::$querySelect['select'], 'limit' => static::$querySelect['limit'], 'orderBy' => static::$querySelect['orderBy'], ]; } if ($useLimit && static::$querySelect['limit'] != '') { static::$querySelect['select'] = ['1']; static::$querySelect['orderBy'] = ''; $sql = 'SELECT COUNT(1) AS quantity FROM (' . static::buildQuery() . ') AS counted'; $queryResult = static::query($sql, $resetQuery); $result = $queryResult[0]['quantity']; } else { static::$querySelect['select'] = [ "COUNT(" . static::table() . "." . static::$primaryKey . ") as quantity", ]; static::$querySelect['limit'] = '1'; static::$querySelect['orderBy'] = ''; $sql = static::buildQuery(); $queryResult = static::query($sql, $resetQuery); $result = $queryResult[0]['quantity']; } if (!$resetQuery) { static::$querySelect['select'] = $backup['select']; static::$querySelect['limit'] = $backup['limit']; static::$querySelect['orderBy'] = $backup['orderBy']; } return (int)$result; } /** * Retrieves an instance by its primary key (usually 'id'). * If no instance is found, returns null. * * @param mixed $id * * @return static|null */ public static function getById(mixed $id): ?static { return static::where(static::$primaryKey, $id)->getFirst(); } /** * Performs a search in the current instance's table. * * @param string $search * Content to search for. * * @param array|null $in * (Optional) Columns to search within (null to search all). * * @return static */ public static function search(string $search, ?array $in = null): static { if ($in == null) { $className = get_called_class(); $in = array_keys((new $className())->getVars()); } $search = static::bind($search); $where = []; if (static::db()->getAttribute(PDO::ATTR_DRIVER_NAME) == 'sqlite') { foreach ($in as $row) { $where[] = "$row LIKE '%' || $search || '%'"; } } else { foreach ($in as $row) { $where[] = "$row LIKE CONCAT('%', $search, '%')"; } } if (static::$querySelect['where'] == '') { static::$querySelect['where'] = join(' OR ', $where); } else { static::$querySelect['where'] = static::$querySelect['where'] . ' AND (' . join(' OR ', $where) . ')'; } return new static(); } /** * Retrieves the results of the SQL query. * * @param bool $resetQuery * (Optional) Indicates whether the query should be reset (defaults to true). * * @return array * Array with instances of the current class resulting from the query. */ public static function get(bool $resetQuery = true): array { $sql = static::buildQuery(); $result = static::query($sql, $resetQuery); $instances = []; foreach ($result as $row) { $instances[] = static::getInstance($row); } return $instances; } /** * Retrieves the first element from the SQL query result. * * @param bool $resetQuery * (Optional) Indicates whether the query should be reset (defaults to true). * * @return static|null * Can return an instance of the current class or null. */ public static function getFirst(bool $resetQuery = true): ?static { static::limit(1); $instances = static::get($resetQuery); return empty($instances) ? null : $instances[0]; } /** * Retrieves all elements from the current instance's table. * * @return array * Contains an array of instances of the current class. */ public static function all(): array { $sql = 'SELECT * FROM ' . static::table(); $result = static::query($sql); $instances = []; foreach ($result as $row) { $instances[] = static::getInstance($row); } return $instances; } /** * Allows defining an attribute's value as null. * Only works for updating an element in the DB, not for inserting. * * @param array $attributes * Attribute or array of attributes that will be set to null. * * @return void */ public function setNull(string ...$attributes): void { foreach ($attributes as $att) { if (!in_array($att, $this->toNull)) { $this->toNull[] = $att; } } } }