583 lines
17 KiB
PHP
583 lines
17 KiB
PHP
<?php
|
|
|
|
namespace EesyPHP\Db;
|
|
|
|
use EesyPHP\Hook;
|
|
use EesyPHP\Log;
|
|
|
|
use function EesyPHP\implode_with_keys;
|
|
|
|
/**
|
|
* @property-read array<string,Attr> $_schema
|
|
* @property-read bool $_is_new
|
|
*/
|
|
class DbObject {
|
|
|
|
/**
|
|
* Possible order directions
|
|
* @var array<string>
|
|
*/
|
|
public const ORDER_DIRECTIONS = ['ASC', 'DESC'];
|
|
|
|
/**
|
|
* Default number of objects by page
|
|
* @var int
|
|
*/
|
|
public const DEFAULT_NB_BY_PAGE = 25;
|
|
|
|
/**
|
|
* The database class
|
|
* @var class-string
|
|
*/
|
|
protected const DB_CLASS = '\\EesyPHP\\Db';
|
|
|
|
/**
|
|
* The table name where are stored these objects
|
|
* @var string
|
|
*/
|
|
protected const TABLE = 'NOTSET';
|
|
|
|
/**
|
|
* Primary keys (optional, default: the first field of the schema)
|
|
* @var array<string>
|
|
*/
|
|
protected const PRIMARY_KEYS = [];
|
|
|
|
/**
|
|
* The default order (optional, default: the first field of the schema)
|
|
* @var string|null
|
|
*/
|
|
protected const DEFAULT_ORDER = null;
|
|
|
|
/**
|
|
* The default order direction
|
|
* @var value-of<DbObject::ORDER_DIRECTIONS>
|
|
*/
|
|
protected const DEFAULT_ORDER_DIRECTION = 'DESC';
|
|
|
|
/**
|
|
* Possible orders (optional, default: all declared fields in schema)
|
|
* @var array<string>|null
|
|
*/
|
|
protected const POSSIBLE_ORDERS = null;
|
|
|
|
/**
|
|
* Object's properties values as stored in database
|
|
* @var array<string,mixed>
|
|
*/
|
|
protected $_properties;
|
|
|
|
/**
|
|
* Flag to known if it's a new object
|
|
* @var bool
|
|
*/
|
|
protected $_is_new;
|
|
|
|
/**
|
|
* Cache of the object's property _schema
|
|
* @var array<string,Attr>|null
|
|
*/
|
|
protected $__schema = null;
|
|
|
|
/**
|
|
* Object constructor
|
|
* @param array<string,mixed> $properties Object's properties as stored in database
|
|
* @param boolean $is_new Set to false if it's an existing object in database
|
|
*/
|
|
public function __construct($properties=null, $is_new=true) {
|
|
$this -> _properties = is_array($properties)?$properties:[];
|
|
foreach($this -> _schema as $prop => $attr)
|
|
if (!array_key_exists($prop, $this -> _properties))
|
|
$this -> _properties[$prop] = $attr->to_db(null);
|
|
$this -> _is_new = boolval($is_new);
|
|
}
|
|
|
|
/**
|
|
* Get object schema
|
|
* @throws DbException
|
|
* @return array<string,Attr> The object's schema
|
|
*/
|
|
protected static function get_schema() {
|
|
throw new DbException("The get_schema() method is not implemented for %s", get_called_class());
|
|
}
|
|
|
|
/**
|
|
* Get object's primary keys
|
|
* @param array<string,Attr>|null $schema The object's schema (optional)
|
|
* @return array<string>
|
|
*/
|
|
public static function primary_keys($schema=null) {
|
|
if (static :: PRIMARY_KEYS)
|
|
return static :: PRIMARY_KEYS;
|
|
$schema = $schema ? $schema : static :: get_schema();
|
|
return [key($schema)];
|
|
}
|
|
|
|
/**
|
|
* Get object's primary keys value
|
|
* @return array<string,mixed>
|
|
*/
|
|
public function get_primary_keys($with_value=false) {
|
|
$pks = [];
|
|
foreach(static :: primary_keys($this -> _schema) as $pk)
|
|
$pks[$pk] = $this -> $pk;
|
|
return $pks;
|
|
}
|
|
|
|
/**
|
|
* Get WHERE clause from object primary keys
|
|
* @throws DbException
|
|
* @return array<string,mixed>
|
|
*/
|
|
protected function get_primary_keys_where_clauses() {
|
|
$where = [];
|
|
foreach(static :: get_primary_keys($this -> _schema) as $prop => $value) {
|
|
if (is_null($value))
|
|
throw new DbException(
|
|
"No value for primary key %s of %s object",
|
|
$prop, get_called_class()
|
|
);
|
|
$where[$prop] = $this -> _schema[$prop] -> to_db($value);
|
|
}
|
|
return $where;
|
|
}
|
|
|
|
/**
|
|
* Get default order
|
|
* @param array<string,Attr>|null $schema The object's schema (optional)
|
|
* @return string
|
|
*/
|
|
public static function default_order($schema=null) {
|
|
if (static :: DEFAULT_ORDER)
|
|
return static :: DEFAULT_ORDER;
|
|
$schema = $schema ? $schema : static :: get_schema();
|
|
return key($schema);
|
|
}
|
|
|
|
/**
|
|
* Get default order direction
|
|
* @return value-of<DbObject::ORDER_DIRECTIONS>
|
|
*/
|
|
public static function default_order_direction() {
|
|
return static :: DEFAULT_ORDER_DIRECTION;
|
|
}
|
|
|
|
/**
|
|
* Get possible orders
|
|
* @return array<string>
|
|
*/
|
|
public static function possible_orders() {
|
|
if (static :: POSSIBLE_ORDERS)
|
|
return static :: POSSIBLE_ORDERS;
|
|
$schema = static :: get_schema();
|
|
return array_keys($schema);
|
|
}
|
|
|
|
/**
|
|
* Get an object's property
|
|
* @param string $prop The property name
|
|
* @throws DbException
|
|
* @return mixed
|
|
*/
|
|
public function __get($prop) {
|
|
switch ($prop) {
|
|
case '_schema':
|
|
if (is_null($this -> __schema))
|
|
$this -> __schema = static :: get_schema();
|
|
return $this -> __schema;
|
|
case '_is_new':
|
|
return $this -> _is_new;
|
|
}
|
|
if (!array_key_exists($prop, $this -> _schema))
|
|
throw new DbException("%s object as no %s field", get_called_class(), $prop);
|
|
return $this -> _schema[$prop] -> from_db(
|
|
array_key_exists($prop, $this -> _properties)?
|
|
$this -> _properties[$prop]:
|
|
null
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Set an object's property
|
|
* @param string $prop The property name
|
|
* @throws DbException
|
|
* @param mixed $value The property value
|
|
*/
|
|
public function __set($prop, $value) {
|
|
if (!array_key_exists($prop, $this -> _schema))
|
|
throw new DbException("%s object as no %s field", get_called_class(), $prop);
|
|
$this -> _properties[$prop] = $this -> _schema[$prop] -> to_db($value);
|
|
}
|
|
|
|
/**
|
|
* Helper to apply changes pass as an associative array on the object
|
|
* @param array<string,mixed> $changes The changes to apply
|
|
* @throws DbException
|
|
* @return void
|
|
*/
|
|
public function apply($changes) {
|
|
foreach($changes as $attr => $value)
|
|
$this -> $attr = $value;
|
|
}
|
|
|
|
/**
|
|
* Get one object from its primary keys
|
|
* @param array<mixed> $args Primary keys value in the same order as primary keys as declared
|
|
* @see static :: PRIMARY_KEYS
|
|
* @throws DbException
|
|
* @return DbObject|null The object if found, else otherwise.
|
|
*/
|
|
public static function get(...$args) {
|
|
$class = get_called_class();
|
|
$schema = static :: get_schema();
|
|
$primary_keys = static :: primary_keys($schema);
|
|
$where = [];
|
|
foreach($primary_keys as $prop) {
|
|
if (empty($args))
|
|
throw new DbException("No value provide for %s", $prop);
|
|
$value = array_shift($args);
|
|
$where[$prop] = $schema[$prop] -> to_db($value);
|
|
}
|
|
|
|
if (!empty($args))
|
|
throw new DbException(
|
|
"Too much arguments provided to get one %s. Expect the following fields: %s",
|
|
$class,
|
|
implode(', ', $primary_keys)
|
|
);
|
|
|
|
$properties = static :: DB_CLASS :: get_one(
|
|
static :: TABLE,
|
|
$where,
|
|
array_keys($schema)
|
|
);
|
|
if ($properties === false)
|
|
return null;
|
|
|
|
return new $class($properties, false);
|
|
}
|
|
|
|
/**
|
|
* Save changes on object
|
|
* @return boolean
|
|
*/
|
|
public function save() {
|
|
Hook :: trigger(get_called_class().'::'.($this -> _is_new?'adding':'updating'), $this);
|
|
foreach($this -> _schema as $prop => $info) {
|
|
if (!$info->required || !is_null($this->$prop)) continue;
|
|
if ($this -> _is_new && property_exists($info, 'autoincrement') && $info->autoincrement)
|
|
continue;
|
|
Log :: warning(
|
|
"%s->save(): required property %s is missing, can't save it.",
|
|
get_called_class(), $prop
|
|
);
|
|
return false;
|
|
}
|
|
if ($this -> _is_new) {
|
|
Log :: debug("%s->save(): object marked as new, use INSERT query", get_called_class());
|
|
$values = [];
|
|
foreach($this -> _properties as $prop => $value)
|
|
if (!is_null($value))
|
|
$values[$prop] = $value;
|
|
$result = static :: DB_CLASS :: insert(static :: TABLE, $values, true);
|
|
if ($result === false)
|
|
return false;
|
|
|
|
// Store object ID if object as an unique integer primary key
|
|
if (is_int($result)) {
|
|
$pks = $this -> primary_keys($this -> _schema);
|
|
if (count($pks) == 1 && $this -> _schema[$this -> $pks[0]] instanceof AttrInt)
|
|
$this -> $pks[0] = $result;
|
|
}
|
|
Log :: info(
|
|
"New %s added (%s)",
|
|
get_called_class(),
|
|
implode_with_keys($this -> get_primary_keys())
|
|
);
|
|
|
|
Hook :: trigger(get_called_class().'::added', $this);
|
|
return true;
|
|
}
|
|
|
|
Log :: debug("%s->save(): object not marked as new, use UPDATE query", get_called_class());
|
|
if (static :: DB_CLASS :: update(
|
|
static :: TABLE,
|
|
$this -> _properties,
|
|
$this -> get_primary_keys_where_clauses()
|
|
)) {
|
|
Log :: info(
|
|
"%s %s updated",
|
|
get_called_class(),
|
|
implode_with_keys($this -> get_primary_keys())
|
|
);
|
|
Hook :: trigger(get_called_class().'::updated', $this);
|
|
return True;
|
|
}
|
|
Log :: error(
|
|
"Error saving changes in %s %s.",
|
|
get_called_class(),
|
|
implode_with_keys($this -> get_primary_keys())
|
|
);
|
|
return False;
|
|
}
|
|
|
|
/**
|
|
* Delete the object
|
|
* @return boolean
|
|
*/
|
|
public function delete() {
|
|
Hook :: trigger(get_called_class().'::deleting', $this);
|
|
if (!static :: DB_CLASS :: delete(
|
|
static :: TABLE,
|
|
$this -> get_primary_keys_where_clauses()
|
|
))
|
|
return False;
|
|
$this -> _is_new = true;
|
|
Hook :: trigger(get_called_class().'::deleted', $this);
|
|
return True;
|
|
}
|
|
|
|
/**
|
|
* List objects
|
|
* @param array<string,mixed> $where Where clauses as associative array of field name and value
|
|
* @return array<DbObject>|false
|
|
*/
|
|
public static function list($where=null) {
|
|
$class = get_called_class();
|
|
$schema = static :: get_schema();
|
|
$rows = static :: DB_CLASS :: get_many(
|
|
static :: TABLE,
|
|
static :: _compute_where_clauses($where),
|
|
array_keys($schema)
|
|
);
|
|
if (!is_array($rows))
|
|
return false;
|
|
$objects = [];
|
|
foreach($rows as $row)
|
|
$objects[] = new $class($row, false);
|
|
return $objects;
|
|
}
|
|
|
|
/**
|
|
* Compute WHERE clauses
|
|
* @param array<string,mixed>|null $where Where clauses as associative array of field name and value
|
|
* @param array<string,Attr>|null $schema The object's schema (optional)
|
|
* @throws DbException
|
|
* @return array<string,mixed>
|
|
*/
|
|
protected static function _compute_where_clauses($where, $schema=null) {
|
|
if (is_null($where)) return [];
|
|
$schema = $schema?$schema:static :: get_schema();
|
|
foreach(array_keys($where) as $prop) {
|
|
if (!array_key_exists($prop, $schema))
|
|
throw new DbException("%s object as no %s field", get_called_class(), $prop);
|
|
$where[$prop] = $schema[$prop] -> to_db($where[$prop]);
|
|
}
|
|
return $where;
|
|
}
|
|
|
|
/**
|
|
* Compute WHERE clauses from word pattern
|
|
* @throws DbException
|
|
* @return array
|
|
*/
|
|
public static function word_to_filters($word) {
|
|
throw new DbException(
|
|
"The word_to_filters() method is not implemented for %s",
|
|
get_called_class()
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Search objects
|
|
* @param array<string,mixed> $params Search parameters as an associative array
|
|
* [
|
|
* 'pattern' => [pattern],
|
|
* 'filters' => ['field1' => 'value1', ...],
|
|
* 'where' => [WHERE clauses as expected by FPdo (aggregate with a AND logical operator)],
|
|
* 'order' => [order by field],
|
|
* 'order_direction' => [ordering direction],
|
|
* 'all' => bool,
|
|
* 'offset' => int,
|
|
* 'limit' => int,
|
|
* // or:
|
|
* 'page' => integer, // default: 1
|
|
* 'nb_by_page' => integer, // default: static :: DEFAULT_NB_BY_PAGE
|
|
* ]
|
|
* @throws DbException
|
|
* @return array|false The search result as an array, or False in case of error.
|
|
* Search result format:
|
|
* [
|
|
* 'first' => [range of the first item in result],
|
|
* 'last' => [range of the last item in result],
|
|
* 'count' => [total number of items return by the search],
|
|
* 'items' => [array of DbObjects],
|
|
* // paged search (simulate in non-paged search):
|
|
* 'page' => [page starting by 1],
|
|
* 'nb_pages' => [number of pages],
|
|
* // non-paged search:
|
|
* 'offset' => [the offset of the search],
|
|
* 'limit' => [the limit of the search],
|
|
* ]
|
|
*/
|
|
public static function search($params) {
|
|
$class = get_called_class();
|
|
$schema = static :: get_schema();
|
|
|
|
$where = isset($params['where'])?$params['where']:[];
|
|
if (isset($params['filters']))
|
|
$where = array_merge(static :: _compute_where_clauses($params['filters']), $where);
|
|
|
|
$patterns_where = array();
|
|
if (isset($params['pattern']) && $params['pattern']) {
|
|
foreach(preg_split('/\s+/', trim($params['pattern'])) as $word) {
|
|
if (!$word) continue;
|
|
$patterns_where[] = static :: word_to_filters($word);
|
|
}
|
|
}
|
|
|
|
$order = static :: default_order();
|
|
if (isset($params['order'])) {
|
|
$orders = static :: possible_orders();
|
|
if (!in_array($order, $orders))
|
|
throw new DbException(
|
|
"Invalid order '%s' clause for objects %s",
|
|
$params['order'], $class
|
|
);
|
|
$order = $params['order'];
|
|
}
|
|
|
|
$order_direction = static :: default_order_direction();
|
|
if (isset($params['order_direction']) && $params['order_direction']) {
|
|
if (!in_array($params['order_direction'], self :: ORDER_DIRECTIONS))
|
|
throw new DbException(
|
|
"Invalid order direction '%s' clause for objects %s",
|
|
$params['order_direction'], $class
|
|
);
|
|
$order_direction=$params['order_direction'];
|
|
}
|
|
|
|
$orderby = "$order $order_direction";
|
|
|
|
$limit = null;
|
|
$offset = 0;
|
|
$page = 1;
|
|
$nb_by_page = static :: DEFAULT_NB_BY_PAGE;
|
|
if (isset($params['all'])) {
|
|
if (isset($params['limit']))
|
|
$limit = intval($params['limit']);
|
|
if (isset($params['offset']))
|
|
$offset = intval($params['offset']);
|
|
}
|
|
else {
|
|
if (isset($params['page']) && $params['page'] > 0) {
|
|
if (isset($params['nb_by_page']) && $params['nb_by_page'] > 0) {
|
|
$nb_by_page = intval($params['nb_by_page']);
|
|
}
|
|
$page = intval($params['page']);
|
|
}
|
|
$offset = ($page - 1) * $nb_by_page;
|
|
$limit = $nb_by_page;
|
|
}
|
|
|
|
try {
|
|
$query = static :: DB_CLASS :: $fpdo -> from(static :: TABLE);
|
|
if (!empty($where))
|
|
$query -> where($where);
|
|
foreach ($patterns_where as $patterns_word)
|
|
call_user_func_array(
|
|
array($query, 'where'),
|
|
array_merge(
|
|
array('('.implode(' OR ', array_keys($patterns_word)).')'),
|
|
array(array_values($patterns_word))
|
|
)
|
|
);
|
|
$result = $query -> orderBy($orderby)
|
|
-> limit($limit)
|
|
-> offset($offset)
|
|
-> execute();
|
|
|
|
if ($result === false) {
|
|
Log :: error('%s :: search() : search in DB return false', $class);
|
|
return false;
|
|
}
|
|
|
|
$items = [];
|
|
foreach ($result -> fetchAll() as $row)
|
|
$items[] = new $class($row, false);
|
|
|
|
if (isset($params['all'])) {
|
|
return array(
|
|
'count' => count($items),
|
|
'first' => 1,
|
|
'last' => count($items),
|
|
'nb_pages' => 1,
|
|
'page' => 1,
|
|
'offset' => $offset,
|
|
'limit' => $limit,
|
|
'items' => $items
|
|
);
|
|
}
|
|
$query_count = static :: DB_CLASS :: $fpdo -> from('item')
|
|
-> select(null)
|
|
-> select('COUNT(*) as count');
|
|
if (!empty($where))
|
|
$query_count -> where($where);
|
|
foreach ($patterns_where as $patterns_word)
|
|
call_user_func_array(
|
|
array($query_count, 'where'),
|
|
array_merge(
|
|
array('('.implode(' OR ', array_keys($patterns_word)).')'),
|
|
array(array_values($patterns_word))
|
|
)
|
|
);
|
|
|
|
$result_count = $query_count -> execute();
|
|
|
|
if ($result_count === false) {
|
|
Log :: debug('%s :: search() : search for count in DB return false', $class);
|
|
return False;
|
|
}
|
|
$count = $result_count -> fetch();
|
|
return array(
|
|
'count' => $count['count'],
|
|
'first' => $offset+1,
|
|
'last' => (
|
|
$offset + $nb_by_page < $count['count']?
|
|
$offset + $nb_by_page:
|
|
$count['count']
|
|
),
|
|
'nb_pages' => ceil($count['count']/$nb_by_page),
|
|
'page' => $page,
|
|
'items' => $items,
|
|
);
|
|
}
|
|
catch (\Exception $e) {
|
|
Log :: exception(
|
|
$e, "An exception occurred searching %s with params %s in database",
|
|
get_called_class(),
|
|
preg_replace("/\n[ \t]*/", " ", print_r($params, true))
|
|
);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Compute CSV export fields as expected by \EesyPHP\Export\CSV
|
|
* @param array<string,Attr>|null $schema The object's schema (optional)
|
|
* @return array
|
|
*/
|
|
static protected function csv_export_fields($schema=null) {
|
|
$schema = $schema?$schema:static :: get_schema();
|
|
$csv_fields = [];
|
|
foreach($schema as $field => $attr)
|
|
$csv_fields[$field] = [
|
|
'label' => $field,
|
|
'from_string' => [$attr, 'from_string'],
|
|
'to_string' => [$attr, 'to_string'],
|
|
];
|
|
return $csv_fields;
|
|
}
|
|
|
|
}
|