eesyphp/src/Db/DbObject.php
Benjamin Renard 347de8eeaf
Add DbObject
2024-02-18 18:27:58 +01:00

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;
}
}