BigOrm.php

<?php

namespace Tlf;

/**
 * Minimal ORM implementation.
 *
 *
 * Declare `$protected ClassName $_article;` and `public int $article_id;` to automagically make `$obj->article` load the related article as a BigOrm object.
 * @tagline (Alpha version) A minimalist ORM for mapping arrays of data to objects with magic getters & some convenience methods
 */
class BigOrm {

    public string $table;
    protected BigDb $db;

    /**
     * Create a BigOrm instance. 
     * @param $db a BigDb instance
     */
    public function __construct(BigDb $db){
        $this->db = $db;
    }

    /**
     * Get a db-representation of your item. This is intended to convert your Orm object into a mysql-storeable array. It is NOT intended to load from the database.
     *
     * @return array<string, mixed>
     *
     * @override required because there is no default implementation
     */
    public function get_db_row(): array {
        throw new \RuntimeException("You must override `get_db_row():array` in your ORM class '".get_class($this)."'");
        return [];
    }
    /**
     * Initialize the Orm object from a database row
     *
     * @param $row array<string, mixed> a row as it would be retrieved from the database, with @key being the column name & @value being the row's value for that column.
     * @return void
     *
     * @override required because there is no default implementation
     */
    public function set_from_db(array $row){
        throw new \RuntimeException("You must override `set_from_db(array \$row)` in your ORM class '".get_class($this)."'");
    }

    /**
     * Convert binary uuid to a string uuid (mysql compatible). 
     * @param $uuid a binary(16) uuid from MYSQL created via `UUID_TO_BIN( UUID() )`
     * @return a VARCHAR(36) compatible $uuid identical to `BIN_TO_UUID( binary_16_representation_of_uuid )`
     */
    public function bin_to_uuid(string $uuid): string{
        $hex = str_split(bin2hex($uuid), 4);
        return 
            $hex[0].$hex[1].'-'
            .$hex[2].'-'
            .$hex[3].'-'
            .$hex[4].'-'
            .$hex[5].$hex[6].$hex[7]
        ;

        // I suspect str_split approach is faster, so removed this
        // $hex = bin2hex($uuid);
        // return
        //     substr($hex,0,8).'-'
        //     .substr($hex,8,4).'-'
        //     .substr($hex,12,4).'-'
        //     .substr($hex,16,4).'-'
        //     .substr($hex,20)
        // ;
    }

    /**
     * Convert a string uuid to a binary uuid (mysql compatible).
     * @param $uuid a VARCHAR(36) representation of a UUID, generated in MySql with `UUID()`
     * @return a BINARY(16) representation of a UUID, generated in MySql with `BIN_TO_UUID( UUID() )`
     */
    public function uuid_to_bin(string $uuid): string{
        $clean = str_replace('-','', $uuid);
        return hex2bin($clean);
    }

    /**
     * Convert a mysql-stored datetime string to a PHP DateTime instance
     * @param $mysql_datetime mysql-stored datetime string
     * @return DateTime instance
     */
    public function str_to_datetime(string $mysql_datetime): \DateTime {
        return \DateTime::createFromFormat('Y-m-d H:i:s', $mysql_datetime);
    }

    /**
     * Convert a PHP DateTime object into a mysql DATETIME string
     * @param $datetime a DateTime object
     * @return a string compatible with MySql's DATETIME type
     */
    public function datetime_to_str(\DateTime $datetime): string {
        return $datetime->format('Y-m-d H:i:s');
    }

    /**
     * Call and return the property getter. For `$prop = 'author'`, call `$this->getAuthor()`
     *
     * @param $prop a property name
     * @return the value from the property getter.
     */
    public function __get(string $prop): mixed {
        $method = 'get'.ucfirst($prop);
        return $this->$method();
    }

    /**
     * Call the property setter. For `$prop = 'author'`, call `$this->setAuthor($value)`
     *
     * @param $prop a property name
     * @param $value the value to set
     * @return void
     */
    public function __set(string $prop, mixed $value){
        $method = 'set'.ucfirst($prop);
        $this->$method($value);
    }

    /**
     * Store the item in the database. If `is_saved()` returns `true`, then use an UPDATE, else use an INSERT. 
     * UPDATEs are performed based on the `int $id` property of the Orm object, assuming an `id int PRIMARY KEY AUTO_INCREMENT` db column.
     *
     * @override if your table does not use a primary key, autoincrement `id`, or if your auto increment column has a different name.
     * @return int id of the item's db row
     */
    public function save(): int {
        $row = $this->get_db_row();
        $row = $this->onWillSave($row);
        if ($this->is_saved()){
            $this->db->update($this->table(), ['id'=>$this->id], $row);
        } else {
            $this->id = $this->db->insert($this->table(), $row);
        }
        $this->onDidSave($row);
        return $this->id;
    }

    /**
     * Delete this item from the database, where the db column `id` matches this item's property `id`
     *
     * @override if db column `id` is not your unique primary key OR if `$this->id` does not correspond to database column `id`.
     * @return `true` if the item was deleted, `false` otherwise. `false` if there is an error or if this item is not already saved in the db.
     */
    public function delete(): bool {
        if ($this->is_saved()){
            $db_row = $this->get_db_row();
            if (!$this->onWillDelete($db_row))return false;
            $did_delete = $this->db->delete($this->table(), ['id'=>$this->id]);
            if ($did_delete){
                unset($this->id);
                $this->onDidDelete($db_row);
                return true;
            }
            else return false;
        } else {
            return false;
        }
    }

    /**
     * Refreshes this item, so it matches what's in the database. Just queries for this item's row (by id), then calls `$this->set_from_db($row)`.
     *
     * @throw RuntimeException if `$this->id` is not set, or if no rows are returned, or if more than one row is returned.
     * @return the old db row, as gotten from `$this->get_db_row()`
     *
     * @override to refresh based on a property/column other than `id`, or if you want different error handling than exceptions.
     */
    public function refresh(): array {
        $old_row = $this->get_db_row();
        if (!isset($this->id)){
            throw new \RuntimeException("Cannot refresh. This item's `id` is not set, so it cannot be refreshed. Class '".get_class($this)."' can override `refresh()` if `id` is not the reference property.");
        }
        $rows = $this->db->select($this->table(), ['id'=>$this->id]);
        if (count($rows)==0){
            throw new \RuntimeException("Cannot refresh. Could not find a row with id '".$this->id."' in table '".$this->table()."'");
        }
        if (count($rows)>1){
            throw new \RuntimeException("Cannot refresh. Multiple rows returned with id '".$this->id."' in table '".$this->table()."'");
        }

        $this->set_from_db($rows[0]);
        
        return $old_row;
    }

    /**
     * Check if the current item is already stored in the database. Default implementation returns true if `id` property isset & is > 0
     *
     * @override if the `id` property/column is not reliable for determining whether your item already exists in the database.
     * @return true if the item is already in the database, false otherwse
     */
    public function is_saved(): bool{
        return isset($this->id) && $this->id > 0;
    }

    /**
     * Get the table name. Default implementation return `$this->table` or the lowercase version of the class name if `$this->table` is null
     *
     * @override if you are not setting the `table` property AND your class's basename does not map to the table's name in the database.
     * @return database table name
     */
    public function table(): string {
        if (isset($this->table))return $this->table;
        $parts = explode('\\', strtolower(get_class($this)));
        $class = array_pop($parts);

        return $class;
    }

    /**
     * Hook called before an item is saved. Returns the correct row to save.
     *
     * @param $row array<string, mixed> the array returned by `get_db_row()`
     * @return array<string, mixed> the correct row to save to database
     *
     * @override if you need to modify `$row` prior to INSERT/UPDATE, or if you need to do something else prior to db storage.
     */
    public function onWillSave(array $row): array {
        return $row;
    }

    /**
     * Hook called after an item is saved.
     *
     * @param $row array<string, mixed> the row that was used for INSERT/UPDATE, typically same as `get_db_row()`, or a modified copy returned by `onWillSave()`
     * @return void
     *
     * @override if you need to query values auto-generated by mysql, or if you need to perform other actions after INSERT/UPDATE
     */
    public function onDidSave(array $row) {
        
    }

    /**
     * Hook called before an item is deleted. 
     *
     * @param $row array<string, mixed> the array returned by `get_db_row()`
     * @return `false` to stop deletion or `true` to continue.
     *
     * @override If there are cases where you want to prevent deletion of an item. Cleanup should go in `onDidDelete(array $row)`, but pre-steps should go here. Ex: Article can only be deleted if its tags are deleted first. Delete tags during onWillDelete & if they fail to delete, then return `false` to prevent article deletion.
     */
    public function onWillDelete(array $row): bool {
        return true;
    }

    /**
     * Hook called after an item is deleted from database.
     *
     * @param $row array<string, mixed> the row that was deleted. This is gotten from `$this->get_db_row()` before the deletion, NOT from the database
     * @return void
     *
     * @override if you need to do some cleanup after deletion
     */
    public function onDidDelete(array $row) {

    }
}