<?php
// last edit: 12.12.2018
// old edit: 22.06.2018

/*
помимо указанных ниже готовых возможностей класса, в объекте так же будут на автомате подхватываться методы со следующими именами:

	->init()
	  	вызывается в __construct, после выполнения всех необходимых предварительных настроек,
	  	позволяя неперекрывать этот метод, но выполнить донастройки, если такие требуются

	->get_<name>()
		вызывается вместо метода get(<name>), при обращении к полю <name>. 
		При необходимости позволяет подкорректировать данные и вернуть их вместо исходных значений.
		Для получения же исходных значений внутри данного метода рекомендуется использовать метод get.

	->set_<name>($value)
		вызывается вместо метода set(<name>, $value), при попытки изменить поле <name>. 
		При необходимости позволяет подкорректировать данные и передать их в объект вместо присваиваемых.
		Для получения передачи значения внутри данного метода рекомендуется использовать метод set.

	->onSave()
		вызывается когда данные были успешно сохранены, и исходные данные изменились.

	->onIfDelete()
		вызывается перед попыткой удаления данных из БД, и позволяет отменить указанное выше, вернув false;

	->onDelete()
		вызывается после успешного удаления данных из БД

	->isInsert()
		вызывается в методе Save, перед тем как определить вызывать ли Update, или Insert.
		Если вернуть true, то будет вызван принудительно Insert

Краткий перечень функционала:
	::$tablename
	::$fields
	::$id_field_name
	::$isIgnoreEditId

	::table($name_table_db)
	::from($array_data)
	::sql($sql_select)
	::load([$where, $other_sql])
	::one([$where, $other_sql])
	::oneById($id_data)
	::count([$where, $other_sql])
	::pages($limit [, $where, $other_sql])

	->isNew()
	->getRow()
	->edit($row_data)
	->setIDField($name_id_field)
	->noIDField()
	->setTable($table_name_db)
	->isEdit()
	->get($field_name)
	->set($field_name, $value)
	->insert([$is_reload])
	->update([$is_reload])
	->save()
	->delete()

	->#field_name
	->#name
		=> get_#name()
		=> set_#name($value)

#### системный функционал
	->__save()
	->__get($field_name)
	->__set($field_name, $value)
	->get_id()
	->set_id($id)
	
Если неуказывать static::$tablename, то по умолчанию будет название класса будет считаться названием таблицы, к которой необходимо обратиться.
Если неуказать static::$id_field_name, то вся функциональность связанная с полем ID будет отключена в описанной модели
Если установить static::$isIgnoreEditId = false, то модель небудет защищать поле static::$id_field_name от изменений
*/


class DBModel{
	/**
	* Наименование таблицы БД, с которой будет работать модель
	* @var string
	*/
	static $tablename;

	/**
	* массив полей, которые необходимо создавать при формировании новой записи (для вставки в insert)
	* @var array
	*/
	static $fields;

	/**
	* Наименование поля, которое является идентификатором строки в БД, по умолчанию = 'id'
	* @var string
	*/
	static $id_field_name;

	/**
	* Если = true, То активна защита поле ID от редактирования
	* Если = false, То поле ид не является автоинкрементом, и защищать его от изменения не стоит
	*/
	static $isIgnoreEditId = true;

	/**
	* Установка наименования таблицы БД, с которой будет работать экземпляр модели
	* @param string $table  наименование таблицы
	* @return class
	*/
	static function table($table){
		static::$tablename = $table;
		return self::class;
	}

	/**
	* Преобразование массива строк таблицы БД, в массив объектов текущей модели
	* @param array $arr массива строк таблицы БД
	* @return array
	*/
	static function from($arr){
		$table = static::$tablename ?: (static::class == 'DBModel' ? false : static::class);
		return array_map(function($row) use ($table){
			$temp = new static($row);
			if($table)
				$temp->setTable($table);
			return $temp;
		}, $arr);		
	}

	/**
	* Делает SQL-запрос, который предпологает получения таблицы, и сразу преобразует результат в массив объектов текущей модели
	* @param $sqlselect  SQL-запрос
	* @return array
	*/
	static function sql($sqlselect){
		return static::from(DB::table($sqlselect));
	}


	/**
	* Делает выборку строк и возвращает массив объектов текущей модели
	* @param string|array $where  условие выборки
	* @param string       $other  дополнительные элементы запроса
	* @return array
	*/
	static function load($where = '', $other = ''){
		$table = static::$tablename ?: (static::class == 'DBModel' ? false : static::class);
		return static::from(DB::select($table, '*', $where, $other));
	}

	/**
	* Делает выборку строк и возвращает объект текущей модели
	* @param string|array $where  условие выборки
	* @param string       $other  дополнительные элементы запроса
	* @return object
	*/
	static function one($where = '', $other = ''){
		$table = static::$tablename ?: (static::class == 'DBModel' ? false : static::class);
		$obj = new static(DB::select_one($table, '*', $where, $other));
		if($table)
			$obj->setTable($table);
		return $obj;
	}

	/**
	* делает выборку по полю ID, и возвращает 1 объект текущей модели
	* @param integer $id  идентификатор п,
	*/
	static function oneById($id){
		$table = static::$tablename ?: (static::class == 'DBModel' ? false : static::class);
		if(!isset(static::$id_field_name))
			return (new static())->setTable($table);
		return static::one([static::$id_field_name => $id]);
	}

	static function count($where = '', $other = ''){
		$table = static::$tablename ?: (static::class == 'DBModel' ? false : static::class);
		return DB::count($table, $where, $other);
	}

	static function pages($limit, $where = '', $other = ''){
		$table = static::$tablename ?: (static::class == 'DBModel' ? false : static::class);
		return DB::count_page($table, $limit, $where, $other);
	}


	private
		$row,
		$copyrow,
		$rowedit,
		$id_field = 'id',
		$new = false,
		$table;

	/**
	* Конструктор объекта модели
	* @param false|array $row  набор данных, или набор полей БД, или false (даст понять, что поля определены в текущем классе, и стоит брать их для формирования новой строки)
	* @return object
	*/
	function __construct($row = false){
		$isNew = $row === false || count($row) == 0 || isset($row[0]);
		$this->new = $isNew;
		$this->row = $this->new ? array_fill_keys($isNew ? (static::$fields ?: []) :$row, null) : $row;
		$this->__save();
		$this->rowedit = array_fill_keys(array_keys($this->row), false);
		$this->table = static::$tablename ?: get_class($this);


		if(isset(static::$id_field_name))
			$this->id_field = static::$id_field_name;

		if(method_exists($this, 'init'))
			$this->init();
	}


	/**
	* возвращает флаг признака новой строки
	* @return boolean
	*/
	function isNew(){
		return $this->new;
	}

	/**
	* возвращает данные в виде массива
	* @return array
	*/
	function getRow(){
		return $this->row;
	}

	/**
	* формирует удобную отладочную таблицу текущего состояния данных объекта модели
	* @return string
	*/
	function __toString(){
		$rUp = [];
		$rDown = [];
		foreach($this->row as $field => $value){
			$valStr = ''.str_replace(["\n","\r"], ['#10','#13'], $value);
			$lenVal = strlen( $valStr ); 
			$diff = mb_strlen($valStr);

			$field_a = $field . ($this->rowedit[$field] ? '*' : '');

			$len = max(strlen($field_a), $diff);
			$diff = $lenVal - $diff;

			$rUp[]   = str_pad($field_a,  $len, ' ', STR_PAD_BOTH);
			$rDown[] = str_pad($valStr, $len < $lenVal ? $len + $diff : $len, ' ', STR_PAD_LEFT);
		}
		$rUp = implode('|',$rUp);
		$rDown = implode('|',$rDown);
		return $rUp . "\n" . str_repeat('-', strlen($rUp)) . "\n" . $rDown;
	}

	/**
	* позволяет отредактировать одновременно несколько полей. Если будут использоваться имена полей, которых нет, то они будут проигнорированы.
	* @param array $row  массив изменений где индекс является наименованием поля, а значение данными поля.
	* @return $this
	*/
	function edit($row){
		foreach($row as $field => $value)
			$this->__set($field, $value);
		return $this;
	}

	/**
	* Позволяет изменить поле идентификатора
	* @param string $name  наименование поля ID
	* @return $this
	*/
	function setIDField($name){
		$this->id_field = $name;
		return $this;
	}

	/**
	* Позволяет указать модели, что для работы с данной таблицей не предусмотрет уникальный идентификатор, и для изменений необходимо формировать проверку исходных данных.
	* @return $this
	*/
	function noIDField(){
		$this->id_field = false;
		return $this;
	}

	/**
	* Позволяет сменить наименовани таблицы данных, с которой работает данный объект модели
	* @param $name  наименование таблицы БД
	* @return $this
	*/
	function setTable($name){
		$this->table = $name;
		return $this;
	}

	/**
	* возвращает TRUE, если значение любого поля отличается от исходного
	* @return boolean
	*/
	function isEdit(){
		return array_reduce($this->rowedit, function($res, $flag){ return $res || $flag; }, false);
	}

	/**
	* Внутренний метод, который сбрасывает состояние измененных полей, и делает текущие данные как исходные.
	*/
	protected function __save(){
		$this->copyrow = $this->row;
		$this->rowedit = array_fill_keys(array_keys($this->row), false);
		if(method_exists($this, 'onSave'))
			$this->onSave();
	}

	/**
	* Возвращает текущее значение указанного поля
	* @param string $name  имя поля
	* @return mixed
	*/
	function get($name){
		return $this->row[$name];
	}

	/**
	* Возвращает текущее значение указанного поля, при этом может вызвать метод дополнительной обработки
	* @param string $name  имя поля
	* @return mixed
	*/
	function __get($name){
		$func ='get_'.$name;
		if(method_exists($this, $func))
			return $this->$func();
		else return $this->get($name);
	}

	/**
	* Проверяет поле на существование
	* @param string $name  имя поля
	* @return boolean
	*/
	function __isset($name){
		return isset($this->row[$name]) || $name == 'id' || method_exists($this,'get_'.$name);
	}

	/**
	* устанавливает новое значение поля, если такое поле существует, и неявляется индексом
	* @param string $name   имя поля
	* @param mixed  $value  новое значение
	*/
	function set($name, $value){
		if(array_key_exists($name, $this->row) && ((static::$isIgnoreEditId && $name != $this->id_field) || !static::$isIgnoreEditId)){
			$this->rowedit[$name] = $this->copyrow[$name] != $value;
			$this->row[$name] = $value;
		}
	}

	/**
	* устанавливает новое значение поля, если такое поле существует, и неявляется индексом, при этом может вызвать метод дополнительной обработки
	* @param string $name   имя поля
	* @param mixed  $value  новое значение
	*/
	function __set($name, $value){
		$func ='set_'.$name;
		if(method_exists($this, $func))
			$this->$func($value);
		else $this->set($name, $value);
	}


	function get_id(){
		if(isset($this->row['id']))
			return $this->row['id'];
		if($this->id_field)
			return $this->row[$this->id_field];
		reset($this->row);
		return current($this->row);
	}

	function set_id($value){
		if(isset($this->row['id']))
			$this->row['id'] = $value;
		elseif($this->id_field)
			$this->row[$this->id_field] = $value;
		else{
			reset($this->row);
			$this->row[key($this->row)] = $value;
		}
	}


	/**
	* Делает вставку записи в БД как новую (предварительно удаляется поле идентификатора, если оно было выбрано).
	* @param boolean $reload  если TRUE, то принудительно делает запрос на получение данных.
	* @return $this
	*/
	function insert($reload = false){
		if($this->table){
			$row = array_filter($this->row, function($val){ return !is_null($val); });
			if($this->id_field){
				if(static::$isIgnoreEditId)
					unset($row[$this->id_field]);
			 	$id = DB::insertGetId($this->table, $row, $this->id_field);
			 	if($id){
			 		$id = is_array($id) ? $id[0] : $id;
			 		if($reload)
				 		$this->row = DB::select_one($this->table, '*', [$this->id_field => $id]);
					else $this->row[$this->id_field] = $id;
				}
			} else {
				$id = DB::insert($this->table, $row);
				if($reload && $id){
					$row = array_filter($this->row, function($val){ return !is_null($val) && !in_array(strtoupper($val), DB::VALUE_NO_STRING);});
				 	$this->row = DB::select_one($this->table, '*', [$row]);
				}
			}

			if($id){
				$this->new = false;
				$this->__save();
			}
		}
		return $this;
	}

	/**
	* Обновляет только измененные данные записи БД соответствующей строки. После возвращает успешность операции.
	* @param boolean $reload  если TRUE, то принудительно делает запрос на получение данных.
	* @return boolean
	*/
	function update($reload = false){
		if($this->table && $this->isEdit()){
			$obj = $this;
			$data = array_filter($this->row, function($field) use($obj){
				return $obj->rowedit[$field];
			}, ARRAY_FILTER_USE_KEY);

			if($this->id_field)
				$rows = DB::update($this->table, $data, [$this->id_field => $this->row[$this->id_field]]);
			else
				$rows = DB::update($this->table, $data, $this->copyrow);
			if($rows > 0){
				$this->new = false;
				$this->__save();
				return true;
			}else false;
		}
		return false;
	}

	/**
	* Производит сохранение данных в БД, в случае, если они новые делает вставку, если же уже есть в БД, то делает их обновление. Возвращает TRUE в случае успешного изменения
	* @return boolean
	*/
	function save(){
		if($this->table && $this->isEdit()){
			$isInsert = false;
			if(method_exists($this, 'isInsert'))
				$isInsert = $this->isInsert();
			if(!$isInsert && ((($this->id_field && isset($this->row[$this->id_field])) || !$this->new)))
				return $this->update();
			else {
				$this->insert();
				return true;
			}
		}
		return false;
	}

	/**
	* Удаляет текущую запись из БД, и возвращает TRUE, если действие удалось
	* @return boolean
	*/
	function delete(){
		if($this->table){
			if(method_exists($this, 'onIfDelete') && $this->onIfDelete() === false)
				return 'noDelete';
			if($this->id_field)
				$rows = DB::delete($this->table, [$this->id_field => $this->row[$this->id_field]]);
			else
				$rows = DB::delete($this->table, $this->copyrow);
			if($rows > 0){
				if(method_exists($this, 'onDelete'))
					$this->onDelete();
				return true;
			}
		}
		return false;
	}

}