<?php 
declare(strict_types=1); 
namespace ParagonIE\Discretion; 
 
use ParagonIE\ConstantTime\Base64UrlSafe; 
use ParagonIE\Discretion\Exception\{ 
    DatabaseException, 
    RecordNotFound 
}; 
use ParagonIE\Discretion\Policies\Unique; 
 
/** 
 * Class Struct 
 * @package ParagonIE\Discretion 
 */ 
abstract class Struct 
{ 
    const TABLE_NAME = ''; 
    const PRIMARY_KEY = ''; 
    const DB_FIELD_NAMES = []; 
    const BOOLEAN_FIELDS = []; 
 
    /** @var int $id */ 
    protected $id = 0; 
 
    /** @var \DateTimeImmutable|null $created */ 
    protected $created = null; 
 
    /** @var \DateTimeImmutable|null $modified */ 
    protected $modified = null; 
 
    /** @var array<string, Struct> $objectCache */ 
    protected static $objectCache = []; 
 
    /** @var string $runtimeCacheKey */ 
    protected static $runtimeCacheKey = ''; 
 
    /** 
     * Get a new struct by its ID 
     * @param int $id 
     * @return static 
     * 
     * @throws RecordNotFound 
     * @throws \Error 
     */ 
    public static function byId(int $id): self 
    { 
        if (empty(static::TABLE_NAME) || empty(static::PRIMARY_KEY) || empty(static::DB_FIELD_NAMES)) { 
            throw new \Error('Struct does not define necessary constants'); 
        } 
        $self = new static(); 
        if ($self instanceof Unique) { 
            if (\array_key_exists($self->getCacheKey($id), self::$objectCache)) { 
                return self::$objectCache[$self->getCacheKey($id)]; 
            } 
        } 
        $db = Discretion::getDatabase(); 
        /** @var array<string, mixed> $row */ 
        $row = $db->row( 
            "SELECT * FROM " . 
                $db->escapeIdentifier((string) static::TABLE_NAME) . 
            " WHERE " . 
                $db->escapeIdentifier((string) static::PRIMARY_KEY) . 
            " = ?", 
            $id 
        ); 
        if (empty($row)) { 
            throw new RecordNotFound(static::class . '::' . $id); 
        } 
        /** @psalm-suppress MixedAssignment */ 
        foreach (static::DB_FIELD_NAMES as $field => $property) { 
            /** 
             * @psalm-suppress MixedArrayOffset 
             * @psalm-suppress MixedAssignment 
             */ 
            $self->{$property} = $row[$field]; 
        } 
        if (isset($row['created'])) { 
            $self->created = new \DateTimeImmutable((string) $row['created']); 
        } 
        if (isset($row['modified'])) { 
            $self->modified = new \DateTimeImmutable((string) $row['modified']); 
        } 
        if ($self instanceof Unique) { 
            self::$objectCache[$self->getCacheKey($id)] = $self; 
        } 
        return $self; 
    } 
 
    /** 
     * @param int $id 
     * @return string 
     * @throws \Error 
     * @throws \SodiumException 
     * @throws \TypeError 
     */ 
    public function getCacheKey(int $id = 0): string 
    { 
        if (empty(static::$runtimeCacheKey)) { 
            static::$runtimeCacheKey = \random_bytes( 
                \ParagonIE_Sodium_Compat::CRYPTO_SHORTHASH_KEYBYTES 
            ); 
        } 
 
        $plaintext = \json_encode([ 
            'class' => \get_class($this), 
            'id' => $id > 0 ? $id : $this->id 
        ]); 
        if (!\is_string($plaintext)) { 
            throw new \Error('Could not calculate cache key'); 
        } 
        return Base64UrlSafe::encode( 
            \ParagonIE_Sodium_Compat::crypto_shorthash( 
                $plaintext, 
                static::$runtimeCacheKey 
            ) 
        ); 
    } 
 
    /** 
     * @return int 
     * @throws DatabaseException 
     */ 
    public function id(): int 
    { 
        if (!$this->id) { 
            throw new DatabaseException('Record does not have a primary key. It may have not been created yet.'); 
        } 
        return $this->id; 
    } 
 
    /** 
     * @return array 
     */ 
    public static function getRuntimeCache(): array 
    { 
        return static::$objectCache; 
    } 
 
    /** 
     * @return bool 
     * @throws \Exception 
     */ 
    public function create(): bool 
    { 
        if ($this->id) { 
            return $this->update(); 
        } 
        $db = Discretion::getDatabase(); 
        $db->beginTransaction(); 
 
        /** @var array<string, mixed> $fields */ 
        $fields = []; 
        /** @psalm-suppress MixedAssignment */ 
        foreach (static::DB_FIELD_NAMES as $field => $property) { 
            if ($field === static::PRIMARY_KEY) { 
                // No 
                continue; 
            } 
            if (\in_array($field, (array) static::BOOLEAN_FIELDS, true)) { 
                /** @psalm-suppress MixedArrayOffset */ 
                $fields[$field] = Discretion::getDatabaseBoolean( 
                    !empty($this->{$property}) 
                ); 
            } else { 
                /** @psalm-suppress MixedArrayOffset */ 
                $fields[$field] = $this->{$property}; 
            } 
        } 
        $this->id = (int) $db->insertGet( 
            (string) (static::TABLE_NAME), 
            $fields, 
            (string) (static::PRIMARY_KEY) 
        ); 
        if ($this instanceof Unique) { 
            self::$objectCache[$this->getCacheKey()] = $this; 
        } 
        return $db->commit(); 
    } 
 
    /** 
     * @return bool 
     * @throws \Exception 
     */ 
    public function update(): bool 
    { 
        if (!($this->id)) { 
            return $this->create(); 
        } 
        $db = Discretion::getDatabase(); 
        $db->beginTransaction(); 
 
        /** @var array<string, mixed> $fields */ 
        $fields = []; 
        /** @psalm-suppress MixedAssignment */ 
        foreach (static::DB_FIELD_NAMES as $field => $property) { 
            if (!\is_string($field)) { 
                throw new \TypeError('Field name must be a string'); 
            } 
            if ($field === static::PRIMARY_KEY) { 
                // No 
                continue; 
            } 
            if (\in_array($field, (array) static::BOOLEAN_FIELDS, true)) { 
                $fields[$field] = Discretion::getDatabaseBoolean( 
                    !empty($this->{$property}) 
                ); 
            } else { 
                /** @psalm-suppress MixedAssignment */ 
                $fields[$field] = $this->{$property}; 
            } 
        } 
        $db->update( 
            (string) (static::TABLE_NAME), 
            $fields, 
            [static::PRIMARY_KEY => $this->id] 
        ); 
        return $db->commit(); 
    } 
 
    /** 
     * Get the property from the object. 
     * 
     * @param string $name 
     * @return mixed 
     * @throws \InvalidArgumentException If the property does not exist. 
     */ 
    public function __get(string $name) 
    { 
        if (!\property_exists($this, $name)) { 
            throw new \InvalidArgumentException('Property ' . $name . ' does not exist.'); 
        } 
        return $this->{$name}; 
    } 
 
    /** 
     * Strict-typed property setter. 
     * 
     * @param string $name 
     * @param mixed $value 
     * @return void 
     * @throws \TypeError 
     */ 
    public function __set(string $name, $value) 
    { 
        if (!\property_exists($this, $name)) { 
            throw new \InvalidArgumentException('Property ' . $name . ' does not exist.'); 
        } 
 
        if ($name === 'id') { 
            // RESERVED 
            throw new \InvalidArgumentException('Cannot override an object\'s primary key.'); 
        } 
 
        if (!\is_null($this->{$name})) { 
            /* Enforce type strictness if only if property had a pre-established type. */ 
            $propType = Discretion::getGenericType($this->{$name}); 
            $valueType = Discretion::getGenericType($value); 
            if ($propType !== $valueType) { 
                throw new \TypeError('Property ' . $name . ' expects type ' . $propType . ', ' . $valueType . ' given.'); 
            } 
        } 
        /** @psalm-suppress MixedAssignment */ 
        $this->{$name} = $value; 
    } 
} 
 
 |