<?php
/**
* Provides the model class, an abstract interface to a database table
*
* @author Camil Staps
*
* BusinessAdmin: administrative software for small companies
* Copyright (C) 2015 Camil Staps (ViviSoft)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
/**
* Thrown in Model when a table row could not be found
*/
class ModelNotFoundException extends Exception {
}
/**
* Thrown in Model when a column cannot be edited
*/
class ModelIllegalAccessException extends Exception {
}
/**
* Thrown in Model when a call to __set() fails
*/
class ModelSetFailedException extends Exception {
}
/**
* An abstract interface to a database table
*/
abstract class Model {
/**
* @var string $table The database table
* @var string $primary_key The table's primary key
* @var string[] $protected_columns Columns that cannot be edited
* @var string[] $fillable_columns Columns that can be edited
* @var string[] $timestamps Columns that are TIMESTAMPs (special treatment in accessor and mutator)
* @var string[] $dates Columns that are DATEs (special treatment in accessor and mutator)
* @var string[] $booleans Columns that are BOOLEANs (special treatment in accessor and mutator)
*/
public static
$table = '',
$primary_key = 'id',
$protected_columns = ['id'],
$fillable_columns = [],
$timestamps = [],
$dates = [],
$booleans = [];
/**
* @var PDO $pdo A PDO instance for database communication
* @var mixed[] $data The column values
*/
protected $pdo, $data;
/**
* Create a new instance
*
* @param PDO $pdo The PDO class, to access the database
* @param int $id The id of the row to fetch
*
* @throws PDOException If something went wrong with the database
* @throws ModelNotFoundException If the id could not be found
*/
public function __construct($pdo, $id) {
$this->pdo = $pdo;
$stmt = $this->pdo->prepare("SELECT * FROM `".self::table()."` WHERE `".static::$primary_key."`=?");
$stmt->execute([$id]);
if ($stmt->rowCount() == 0) {
throw new ModelNotFoundException("The ".static::$table." with id '$id' could not be found.");
}
$this->data = $stmt->fetch(PDO::FETCH_ASSOC);
}
/**
* Set a column value
*
* @param string $key The column
* @param mixed $value The value
*
* @throws PDOException Database error
*/
public function __set($key, $value) {
if (!in_array($key, static::$fillable_columns)) {
throw new ModelIllegalAccessException("Column `".self::table()."`.`$key` cannot be edited.");
}
if ($this->data[$key] == $value) {
return;
}
$stmt = $this->pdo->prepare("UPDATE `".self::table()."` SET `$key`=? WHERE `".static::$primary_key."`=?");
$stmt->execute([
$this->mutator($key, $value),
$this->data[static::$primary_key]
]);
if ($stmt->rowCount() != 1) {
throw new ModelEditFailedException("Failed to update `".self::table()."`.`$key` to '$value'.");
}
$this->data[$key] = $value;
}
/**
* Get a column value
*
* @param string $key The column
*
* @return mixed The value
*/
public function __get($key) {
return $this->accessor($key, $this->data[$key]);
}
/**
* Create a new row
*
* @param PDO $pdo Database connection
* @param mixed[] $values The column values, in the order of $fillable_columns
*
* @throws PDOException Database error
*
* @return self The new item
*/
public static function create($pdo, $values) {
$class = get_called_class();
$columns = array_combine(static::$fillable_columns, $values);
$questions = [];
foreach ($columns as $column => $value) {
$columns[$column] = $class::mutator($column, $value);
$questions[] = '?';
}
$stmt = $pdo->prepare(
"INSERT INTO `".self::table()."` " .
"(`" . implode('`, `', array_keys($columns)) . "`) " .
"VALUES (" . implode(',', $questions) . ")");
$stmt->execute(array_values($columns));
return new $class($pdo, $pdo->lastInsertId());
}
/**
* Search for rows, return only ids
*
* @param PDO $pdo Database connection
* @param string[] $where Where clauses, to be ANDed
* @param mixed[] $values Variables to bind to the where clauses
*
* @throws PDOException Database error
*
* @return int[] Array of ids
*/
public static function searchIds($pdo, $where = [], $values = []) {
$stmt = $pdo->prepare("SELECT `id` FROM `".static::table()."`" . ((count($where) > 0) ? (" WHERE (" . implode(') AND (', $where) . ")") : ""));
$stmt->execute($values);
$ids = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$ids[] = $row['id'];
}
return $ids;
}
/**
* Search for rows
*
* @param PDO $pdo Database connection
* @param string[] $where Where clauses, to be ANDed
* @param mixed[] $values Variables to bind to the where clauses
*
* @throws PDOException Database error
*
* @return self[] Array of rows
*/
public static function search($pdo, $where = [], $values = []) {
$class = get_called_class();
$items = [];
foreach (self::searchIds($pdo, $where, $values) as $id) {
$items[] = new $class($pdo, $id);
}
return $items;
}
/**
* Number of matching rows
*
* @param PDO $pdo Database connection
* @param string[] $where Where clauses, to be ANDed
* @param mixed[] $values Variables to bind to the where clauses
*
* @throws PDOException Database error
*
* @return int The number of matching rows
*/
public static function count($pdo, $where = [], $values = []) {
$class = get_called_class();
$stmt = $pdo->prepare("SELECT COUNT(*) FROM `".static::table()."`" . ((count($where) > 0) ? (" WHERE (" . implode(') AND (', $where) . ")") : ""));
$stmt->execute($values);
return $stmt->fetchColumn();
}
/**
* Post-__get() hook to modify the value
*
* @param string $key The column
* @param string $value The value
*
* @return mixed The modified value
*/
protected static function accessor($key, $value) {
if (is_null($value)) {
return null;
} elseif (in_array($key, static::$booleans)) {
return (bool) $value;
} elseif (in_array($key, static::$dates) || in_array($key, static::$timestamps)) {
return strtotime($value);
} else {
return $value;
}
}
/**
* Pre-__set() hook to modify a value
*
* @param string $key The column
* @param mixed $value The value
*
* @return string The modified value
*/
protected static function mutator($key, $value) {
if (in_array($key, static::$dates) && is_int($value)) {
return date('Y-m-d', $value);
} elseif (in_array($key, static::$timestamps) && is_int($value)) {
return date('Y-m-d H:i:s', $value);
} else {
return (string) $value;
}
}
/**
* Delete the row
*
* @throws PDOException Database error
*
* @return bool True iff the row was really deleted
*/
public function delete() {
$stmt = $this->pdo->prepare("DELETE FROM `".self::table()."` WHERE `".static::$primary_key."`=?");
$stmt->execute([$this->data[static::$primary_key]]);
return $stmt->rowCount() != 0;
}
/**
* The actual table, after adding prefixes and the like
*
* @return string The database table
*/
private static function table() {
return Constants::db_prefix . static::$table;
}
}