1:   2:   3:   4:   5:   6:   7:   8:   9:  10:  11:  12:  13:  14:  15:  16:  17:  18:  19:  20:  21:  22:  23:  24:  25:  26:  27:  28:  29:  30:  31:  32:  33:  34:  35:  36:  37:  38:  39:  40:  41:  42:  43:  44:  45:  46:  47:  48:  49:  50:  51:  52:  53:  54:  55:  56:  57:  58:  59:  60:  61:  62:  63:  64:  65:  66:  67:  68:  69:  70:  71:  72:  73:  74:  75:  76:  77:  78:  79:  80:  81:  82:  83:  84:  85:  86:  87:  88:  89:  90:  91:  92:  93:  94:  95:  96:  97:  98:  99: 100: 101: 102: 103: 104: 105: 106: 107: 108: 109: 110: 111: 112: 113: 114: 115: 116: 117: 118: 119: 120: 121: 122: 123: 124: 125: 126: 127: 128: 129: 130: 131: 132: 133: 134: 135: 136: 137: 138: 139: 140: 141: 142: 143: 144: 145: 146: 147: 148: 149: 150: 151: 152: 153: 154: 155: 156: 157: 158: 159: 160: 161: 162: 163: 164: 165: 166: 167: 168: 169: 170: 171: 172: 173: 174: 175: 176: 177: 178: 179: 180: 181: 182: 183: 184: 185: 186: 187: 188: 189: 190: 191: 192: 193: 194: 195: 196: 197: 198: 199: 200: 201: 202: 203: 204: 205: 206: 207: 208: 209: 210: 211: 212: 213: 214: 215: 216: 217: 218: 219: 220: 221: 222: 223: 224: 225: 226: 227: 228: 229: 230: 231: 232: 233: 234: 235: 236: 237: 238: 239: 240: 241: 242: 243: 244: 245: 246: 247: 248: 249: 250: 251: 252: 253: 254: 255: 256: 257: 258: 259: 260: 261: 262: 263: 264: 265: 266: 267: 268: 269: 270: 271: 272: 273: 274: 275: 276: 277: 278: 279: 280: 281: 282: 283: 284: 285: 286: 287: 288: 289: 290: 291: 292: 293: 294: 295: 296: 297: 298: 299: 300: 301: 302: 303: 304: 305: 
<?php

declare(strict_types=1);

namespace Wtf\ORM;

use Exception;
use Respect\Validation\Exceptions\NestedValidationException;
use Slim\Collection;

abstract class Entity extends \Wtf\Root
{
    protected $relationObjects = [];
    protected $scheme;

    /**
     * Get short entity name (without namespace)
     * Helper function, required for lazy load.
     *
     * @return string
     */
    protected function __getEntityName(): string
    {
        return ($pos = \strrpos(\get_class($this), '\\')) ? \substr(\get_class($this), $pos + 1) : \get_class($this);
    }

    /**
     * Magic relation getter.
     *
     * @param null|string $method
     * @param array       $params
     */
    public function __call(?string $method = null, array $params = [])
    {
        $parts = \preg_split('/([A-Z][^A-Z]*)/', $method, -1, PREG_SPLIT_NO_EMPTY | PREG_SPLIT_DELIM_CAPTURE);
        $type = \array_shift($parts);
        $relation = \strtolower(\implode('_', $parts));

        if ('get' === $type && isset($this->getRelations()[$relation])) {
            return $this->loadRelation($relation);
        }

        return parent::__call($method, $params);
    }

    /**
     * Get entity scheme.
     *
     * @return array
     */
    public function getScheme(): array
    {
        if (null === $this->scheme) {
            $raw = $this->medoo->query('DESCRIBE '.$this->getTable())->fetchAll();
            $this->scheme = [];
            foreach ($raw as $field) {
                $this->scheme[$field['Field']] = $field;
            }
        }

        return $this->scheme;
    }

    /**
     * Save entity data in db.
     *
     * @param bool $validate
     *
     * @throws Exception if entity data is not valid
     *
     * @return Entity
     */
    public function save(bool $validate = true): self
    {
        if ($validate && $this->validate()) {
            throw new Exception('Entity '.$this->__getEntityName().' data is not valid');
        }

        /**
         * Remove fields that not exists in DB table scheme,
         * to avoid thrown exceptions on saving garbadge fields.
         */
        $scheme = \array_keys($this->getScheme());
        foreach ($this->data as $key => $value) {
            if (!\in_array($key, $scheme, true)) {
                unset($this->data[$key]);
            }
        }

        if ($this->getId()) {
            $this->medoo->update($this->getTable(), $this->data, ['id' => $this->getId()]);
        } else {
            $this->medoo->insert($this->getTable(), $this->data);
            $this->setId($this->medoo->id());
        }
        $this->sentry->breadcrumbs->record([
            'message' => 'Entity '.$this->__getEntityName().'::save()',
            'data' => ['query' => $this->medoo->last()],
            'category' => 'Database',
            'level' => 'info',
        ]);

        return $this;
    }

    /**
     * Validate entity data.
     *
     * @param string $method Validation for method, default: save
     *
     * @return array [['field' => 'error message']]
     */
    public function validate(string $method = 'save'): array
    {
        $errors = [];
        foreach ($this->getValidators()[$method] ?? [] as $field => $validator) {
            try {
                $validator->setName($field)->assert($this->get($field));
            } catch (NestedValidationException $e) {
                $errors[$field] = $e->getMessages();
            }
        }

        return $errors;
    }

    /**
     * Load entity (data from db).
     *
     * @param mixed  $value  Field value (eg: id field with value = 10)
     * @param string $field  Field name, default: id
     * @param array  $fields Fields (columns) to load, default: all
     *
     * @return Entity
     */
    public function load($value, $field = 'id', array $fields = null): self
    {
        $data = $this->medoo->get($this->getTable(), $fields ?? '*', [$field => $value]);
        $this->data = \is_array($data) ? $data : []; //handle empty result gracefuly
        $this->sentry->breadcrumbs->record([
            'message' => 'Entity '.$this->__getEntityName().'::load('.$value.', '.$field.', ['.\implode(', ', $fields ?? []).')',
            'data' => ['query' => $this->medoo->last()],
            'category' => 'Database',
            'level' => 'info',
        ]);

        return $this;
    }

    /**
     * Get all entities from db.
     *
     * @param array $where  Where clause
     * @param bool  $assoc  Return collection of entity objects OR of assoc arrays
     * @param array $fields Fields to load, default is all
     *
     * @return Collection
     */
    public function loadAll(array $where = [], bool $assoc = false, array $fields = null): Collection
    {
        $allData = $this->medoo->select($this->getTable(), $fields ? $fields : '*', $where);
        $this->sentry->breadcrumbs->record([
            'message' => 'Entity '.$this->__getEntityName().'::loadAll('.\print_r($where, true).', '.$assoc.', '.\print_r($fields, true).')',
            'data' => ['query' => $this->medoo->last()],
            'category' => 'Database',
            'level' => 'info',
        ]);
        $items = [];
        foreach ($allData as $data) {
            $items[] = ($assoc) ? $data : $this->container['entity']($this->__getEntityName())->setData($data);
        }

        return new Collection($items);
    }

    /**
     * Load realated entity by relation name.
     *
     * @param string $name Relation name
     *
     * @return null|Collection|Entity
     */
    public function loadRelation(string $name)
    {
        if (!isset($this->relationObjects[$name]) || empty($this->relationObjects[$name])) {
            $relation = $this->getRelations()[$name];
            if (!$relation || !$relation['entity'] || !$this->get($relation['key'] ?? 'id')) {
                return null;
            }

            $entity = $this->entity($relation['entity']);
            $type = $relation['type'] ?? 'has_one';
            $key = $relation['key'] ?? ('has_one' === $type ? $this->__getEntityName().'_id' : 'id');
            $foreignKey = $relation['foreign_key'] ?? ('has_one' === $type ? 'id' : $this->__getEntityName().'_id');
            $assoc = $relation['assoc'] ?? false;
            $this->relationObjects[$name] = ('has_one' === $type) ? $entity->load($this->get($key), $foreignKey) : $entity->loadAll([$foreignKey => $this->get($key)], $assoc);
        }

        return $this->relationObjects[$name] ?? null;
    }

    /**
     * Determine whether the target data existed.
     *
     * @param array $where
     *
     * @return bool
     */
    public function has(array $where = []): bool
    {
        return $this->medoo->has($this->getTable(), $where);
    }

    /**
     * Get count of items by $where conditions.
     *
     * @param array $where Where clause
     *
     * @return int
     */
    public function count(array $where = []): int
    {
        return $this->medoo->count($this->getTable(), $where);
    }

    /**
     * Delete entity row from db.
     *
     * @return bool
     */
    public function delete(): bool
    {
        return (bool) $this->medoo->delete($this->getTable(), ['id' => $this->getId()]);
    }

    /**
     * Return entity table name.
     *
     * @return string
     */
    abstract public function getTable(): string;

    /**
     * Get list of field validations
     * Structure:
     * <code>
     * [
     *     '<method>' => [
     *         '<field_name>' => v::stringType()->length(1, 255),
     *          //...
     *     ],
     * ];
     * </code>
     * Example: ['save' => ['name' => v::stringType()->length(1,255)]].
     *
     * @return array
     */
    abstract public function getValidators(): array;

    /**
     * Return array of entity relations
     * <code>
     * //structure
     * [
     *     'relation__name' => [
     *         'entity' => 'another_entity_name',
     *         'type' => 'has_one', //default, other options: has_many
     *         'key' => 'current_entity_key', //optional, default for has_one: <current_entity>_id, for has_many: id
     *         'foreign_key' => 'another_entity_key', //optional, default for has_one: id, for has_many: '<current_entity>_id'
     *         'assoc' => false, //optional, return data arrays instead of objects on "has_many", default: false
     *      ],
     * ];.
     *
     * //Example (current entity: blog post, another entity: user)
     * [
     *     'author' => [ //has_one
     *         'entity' => 'user',
     *         'key' => 'author_id',
     *         'foreign_key' => 'id'
     *     ],
     * ];
     * //Example (same as above, but with default values)
     * [
     *     'author' => [
     *         'entity' => 'user',
     *     ],
     * ];
     * //This example can be called like $blogPostEntity->getAuthor()
     *
     * //Example (current entity: user, another entity: blog post)
     * [
     *     'posts' => [
     *         'entity' => 'post',
     *         'type' => 'has_many',
     *         'foreign_key' => 'author_id',
     *     ],
     * ]
     * //This example can be called like $userEntity->getPosts()
     * </code>
     *
     * @return array
     */
    abstract public function getRelations(): array;
}