diff --git a/src/Common/PropertiesState.php b/src/Common/PropertiesState.php new file mode 100644 index 0000000..916e7e0 --- /dev/null +++ b/src/Common/PropertiesState.php @@ -0,0 +1,183 @@ +parentSetProperty($key, $value); + + return $this->setDirty($key); + } + + /** + * Get whether this model has any modified properties + * + * @param array $properties + * + * @return bool + */ + protected function hasModified(...$properties) + { + if (empty($properties)) { + return ! empty($this->modified); + } + + foreach ($properties as $key) { + if ($this->isModified($key)) { + return true; + } + } + + return false; + } + + /** + * Get all modified properties of this model + * + * @return array + */ + public function getModifiedProperties() + { + return array_intersect_key($this->properties, $this->modified); + } + + /** + * Get the whether the given property is modified + * + * @param $key + * + * @return bool + */ + protected function isModified($key) + { + return isset($this->modified[$key]) && isset($this->properties[$key]); + } + + /** + * Clear the modified properties of this model + * + * @return $this + */ + protected function resetDirty() + { + $this->modified = []; + + return $this; + } + + /** + * Get whether this model's state has remained unchanged + * + * @param mixed ...$properties + * + * @return bool + */ + public function isClean(...$properties): bool + { + return ! $this->isDirty(...$properties); + } + + /** + * Get whether this model's state has been modified + * + * @param mixed ...$properties + * + * @return bool + */ + public function isDirty(...$properties): bool + { + return $this->hasModified(...$properties); + } + + /** + * Mark the given property as dirty/modified + * + * @param $property + * + * @return $this + */ + public function setDirty($property) + { + $this->modified[$property] = true; + + return $this; + } + + /** + * Get whether this instance is a new record + * + * @return bool + */ + public function isNewRecord(): bool + { + return $this->newRecord; + } + + /** + * @return bool + */ + public function isRemoved(): bool + { + return $this->removed; + } + + /** + * Set whether this model's primary key is auto incremented + * + * @param bool $autoIncremented + * + * @return $this + */ + public function setAutoIncremented(bool $autoIncremented): self + { + $this->autoIncremented = $autoIncremented; + + return $this; + } + + /** + * Get whether the primary key of this model is auto incremented + * + * @return bool + */ + public function isAutoIncremented(): bool + { + return $this->autoIncremented; + } +} diff --git a/src/Common/PropertiesWithDefaults.php b/src/Common/PropertiesWithDefaults.php index e8d3a84..1159789 100644 --- a/src/Common/PropertiesWithDefaults.php +++ b/src/Common/PropertiesWithDefaults.php @@ -7,8 +7,8 @@ trait PropertiesWithDefaults { - use \ipl\Stdlib\Properties { - \ipl\Stdlib\Properties::getProperty as private parentGetProperty; + use PropertiesState { + PropertiesState::getProperty as private parentGetProperty; } protected function getProperty($key) diff --git a/src/Model.php b/src/Model.php index 0a05819..434557c 100644 --- a/src/Model.php +++ b/src/Model.php @@ -3,7 +3,12 @@ namespace ipl\Orm; use ipl\Orm\Common\PropertiesWithDefaults; +use ipl\Orm\Compat\FilterProcessor; use ipl\Sql\Connection; +use ipl\Sql\Delete; +use ipl\Sql\Insert; +use ipl\Sql\Update; +use ipl\Stdlib\Filter; /** * Models represent single database tables or parts of it. @@ -13,12 +18,25 @@ abstract class Model implements \ArrayAccess, \IteratorAggregate { use PropertiesWithDefaults; - final public function __construct(array $properties = null) + /** @var string Indicates whether insert() has successfully inserted a new entry into the DB */ + public const STATE_INSERTED = 'stateInserted'; + + /** @var string Whether the update() has updated this model successfully */ + public const STATE_UPDATED = 'stateUpdated'; + + /** @var string Whether remove() has removed this model successfully */ + public const STATE_REMOVED = 'stateRemoved'; + + /** @var string Whether this model is in clean state/is unchanged */ + public const STATE_CLEAN = 'stateClean'; + + final public function __construct(array $properties = null, bool $isNewRecord = true) { if ($this->hasProperties()) { $this->setProperties($properties); } + $this->newRecord = $isNewRecord; $this->init(); } @@ -74,6 +92,21 @@ public static function on(Connection $db) ->setModel(new static()); } + /** + * Get the prepared insert query of this model + * + * @param Connection $conn + * @param array $properties + * + * @return ScopedQuery + */ + public static function insert(Connection $conn, array $properties) + { + return (new static()) + ->setProperties($properties) + ->prepareInsert($conn); + } + /** * Get the model's default sort * @@ -129,4 +162,137 @@ public function createRelations(Relations $relations) protected function init() { } + + /** + * Save this model to the database + * + * Determines automagically whether it INSERT or UPDATE this model + * + * @param Connection $conn + * + * @return string + */ + public function save(Connection $conn) + { + if ($this->isClean()) { + return self::STATE_CLEAN; + } + + if (! $this->isNewRecord()) { // Is modified + $this->prepareUpdate($conn)->execute(); + + $this->resetDirty(); + + return self::STATE_UPDATED; + } else { + $this->prepareInsert($conn)->execute(); + + if ($this->isAutoIncremented()) { + $this->{$this->getKeyName()} = $conn->lastInsertId(); + } + + $this->newRecord = false; + $this->resetDirty(); + + return self::STATE_INSERTED; + } + } + + /** + * Remove a database entry matching the given filter + * + * @param Connection $conn + * @param ?Filter\Rule $filter + * + * @return string + */ + public function remove(Connection $conn, Filter\Rule $filter = null) + { + if ($this->isNewRecord()) { + throw new \LogicException('Cannot delete an entry which does not exists'); + } + + if ($this->isRemoved()) { + throw new \LogicException('Cannot delete already deleted entry'); + } + + $delete = new Delete(); + $delete->from($this->getTableName()); + $this->applyFilter($delete, $filter); + + $query = new ScopedQuery($conn, $delete); + $query->execute(); + + $this->removed = true; + + return self::STATE_REMOVED; + } + + protected function prepareUpdate(Connection $conn, Filter\Rule $filter = null) + { + if ($this->isNewRecord()) { + throw new \LogicException('Cannot update a new entry'); + } + + if ($this->isRemoved()) { + throw new \LogicException('Cannot update removed entry'); + } + + $update = new Update(); + $update + ->table($this->getTableName()) + ->set($this->getModifiedProperties()); + + $this->applyFilter($update, $filter); + + return new ScopedQuery($conn, $update); + } + + protected function prepareInsert(Connection $conn) + { + if (! $this->isNewRecord()) { + throw new \LogicException('Cannot insert already existing entry'); + } + + $properties = $this->getModifiedProperties(); + if (empty($properties)) { + $properties = $this->properties; + } + + if (! $this->isAutoIncremented() && ! isset($properties[$this->getKeyName()])) { + throw new \Exception('Cannot insert entry without a primary key'); + } + + $insert = new Insert(); + $insert->into($this->getTableName()); + + $insert->values($properties); + + return new ScopedQuery($conn, $insert); + } + + /** + * Apply the given filter or create custom filter based on the PK(s) + * + * @param Insert|Update|Delete $query + * @param Filter\Rule|null $filter + * + * @return $this + */ + protected function applyFilter($query, Filter\Rule $filter = null) + { + if (! $filter) { + $filter = Filter::all(); + $keys = ! is_array($this->getKeyName()) ? [$this->getKeyName()] : $this->getKeyName(); + + foreach ($keys as $key) { + $filter->add(Filter::equal($key, (int) $this->{$key})); + } + } + + $where = FilterProcessor::assembleFilter($filter); + $query->where(...array_reverse($where)); + + return $this; + } } diff --git a/src/ScopedQuery.php b/src/ScopedQuery.php new file mode 100644 index 0000000..c5bc812 --- /dev/null +++ b/src/ScopedQuery.php @@ -0,0 +1,63 @@ +conn = $conn; + $this->query = $query; + } + + /** + * Get the underlying base query + * + * @return Delete|Insert|Update + */ + public function getBaseQuery() + { + return $this->query; + } + + /** + * Get database connection + * + * @return Connection + */ + public function getConn(): Connection + { + return $this->conn; + } + + /** + * Run the underlying query + * + * @return \PDOStatement + */ + public function execute() + { + return $this->getConn()->prepexec($this->query); + } + + /** + * Dump the underlying assembled query + * + * @return array + */ + public function dump() + { + return $this->getConn()->getQueryBuilder()->assemble($this->query); + } +}