Last active
August 28, 2021 23:41
-
-
Save stecman/f74b70f5e549f6c73b5baa4154bc7caa to your computer and use it in GitHub Desktop.
Phalcon PHP modifiable array assignment of relationships on new objects
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
namespace Soulbudget\Model; | |
use Phalcon\Mvc\Model; | |
use Soulbudget\Model\Relations\UnsavedRelationList; | |
/** | |
* Model that allows read/write access to relation arrays on unsaved instances. | |
*/ | |
abstract class BaseModel extends Model | |
{ | |
/** | |
* Marker if this model has been saved or came from the database | |
* (ID could be used instead, but this is more generic) | |
* @var bool | |
*/ | |
private $isNew = true; | |
/** | |
* List of relation aliases that have UnsavedRelationLists | |
* @var UnsavedRelationList[] | |
*/ | |
private array $unsavedRelations = []; | |
/** | |
* Check if this instance has been written to or came from the database | |
*/ | |
public function isNew() : bool { | |
return $this->isNew; | |
} | |
protected function afterSave() | |
{ | |
$this->isNew = false; | |
} | |
protected function afterFetch() | |
{ | |
$this->isNew = false; | |
} | |
/** | |
* Amend Phalcon's save routine to push related models into its save queue | |
*/ | |
public function prepareSave() : bool | |
{ | |
// Expand any unsaved relation lists to arrays for the framework to save | |
if (count($this->unsavedRelations)) { | |
foreach ($this->unsavedRelations as $lowerProperty => $list) { | |
parent::__set($lowerProperty, $list->toArray()); | |
} | |
$this->unsavedRelations = []; | |
} | |
return true; | |
} | |
/** | |
* Make array-like relationships more consistent than default Phalcon behaviour | |
* | |
* @see UnsavedRelationList | |
*/ | |
public function __get($property) | |
{ | |
// Return an array-like object to work with relations before saving | |
// For non-new objects, ResultSet is returned. | |
if ($this->isNew()) { | |
// Check if there's already an unsaved relation list here | |
$lowerProperty = strtolower($property); | |
$hasUnsavedRelation = isset($this->unsavedRelations[$lowerProperty]); | |
if (!$hasUnsavedRelation) { | |
// Check if this magic property is the right kind of relation | |
$relation = $this->getModelsManager()->getRelationByAlias(static::class, $property); | |
if ($relation && $relation->getType() == Model\Relation::HAS_MANY) { | |
// Intercept and add a new UnsavedRelation list for this property | |
// This allows array append operations when the relation is null initially | |
$list = new UnsavedRelationList(); | |
$this->unsavedRelations[$lowerProperty] = $list; | |
// Flag to the framework that there's something related to save | |
$this->dirtyRelated[$lowerProperty] = $list; | |
$this->dirtyState = self::DIRTY_STATE_TRANSIENT; | |
return $list; | |
} | |
} | |
} | |
return parent::__get($property); | |
} | |
/** | |
* Replace array values being set on relations with UnsavedRelationLists for consistency | |
*/ | |
public function __set($property, $value) | |
{ | |
// Return an array-like object to work with relations before saving | |
// For non-new objects, ResultSet is returned. | |
if ($this->isNew() && is_array($value)) { | |
// Check if this magic property is a relation | |
$relation = $this->getModelsManager()->getRelationByAlias(static::class, $property); | |
if ($relation && ($relation->getType() == Model\Relation::HAS_MANY || $relation->getType() == Model\Relation::HAS_MANY_THROUGH)) { | |
$list = new UnsavedRelationList($value); | |
// Remember in our own relation list | |
$lowerProperty = strtolower($property); | |
$this->unsavedRelations[$lowerProperty] = $list; | |
// Flag to the framework that there's something related to save | |
$this->dirtyRelated[$lowerProperty] = $list; | |
$this->dirtyState = self::DIRTY_STATE_TRANSIENT; | |
} | |
} | |
parent::__set($property, $value); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
namespace Soulbudget\Model\Relations; | |
use Phalcon\Mvc\ModelInterface; | |
/** | |
* Array-like list of related models for unsaved items. | |
* | |
* Phalcon normally returns a ResultSet instance when unsaved relations are accessed, which is read-only. | |
* Additionally, when an unsaved relation is set to an array, Phalcon will save correctly, but offers no | |
* way to retrieve, append to or modify that list of related items before saving: it can only be overwritten. | |
* | |
* This class provides an array-like object intended for silently wrapping arrays of related objects, | |
* which makes relations on new objects readable and modifiable before writing to the database. | |
* | |
* As arrays are passed by value in PHP, using __get() with the array append operator is not possible and throws: | |
* | |
* Indirect modification of overloaded property X has no effect | |
* | |
* It's normally possible to solve this by changing __get to return by reference, but as we can't modify | |
* the base class's signature (Model::__get()), this UnsavedRelationList object is passed around instead. | |
*/ | |
class UnsavedRelationList implements \ArrayAccess, \Iterator, \Countable | |
{ | |
/** | |
* List of relations | |
* @var ModelInterface[] | |
*/ | |
protected $members = []; | |
/** | |
* Cursor in the $members array | |
* @var int | |
*/ | |
protected $index = 0; | |
public function __construct($source = null) | |
{ | |
// Nothing to copy | |
if ($source === null) { | |
return; | |
} | |
// Copy members with type checking | |
if (is_array($source) || $source instanceof \Traversable) { | |
foreach ($source as $item) { | |
$this[] = $item; | |
} | |
} else { | |
// Unknown input | |
$type = $this->getTypeForError($source); | |
throw new \InvalidArgumentException("Source data must be an array or Traversable. Got $type"); | |
} | |
} | |
public function save() | |
{ | |
foreach ($this->members as $model) { | |
$model->save(); | |
} | |
} | |
public function toArray() : array | |
{ | |
return $this->members; | |
} | |
// Array-like interface implementations | |
/** | |
* @return ModelInterface | |
*/ | |
public function current() | |
{ | |
return $this->members[$this->index]; | |
} | |
public function next() | |
{ | |
$this->index++; | |
} | |
public function key() | |
{ | |
return $this->index; | |
} | |
public function valid() | |
{ | |
return $this->index < count($this->members); | |
} | |
public function rewind() | |
{ | |
$this->index = 0; | |
} | |
public function offsetExists($offset) | |
{ | |
return isset($this->members[$offset]); | |
} | |
/** | |
* @param mixed $offset | |
* @return ModelInterface | |
*/ | |
public function offsetGet($offset) | |
{ | |
return $this->members[$offset]; | |
} | |
public function offsetSet($offset, $value) | |
{ | |
// Only accept ModelInterfaces | |
if (!($value instanceof ModelInterface)) { | |
$type = $this->getTypeForError($value); | |
throw new \InvalidArgumentException("Value must be an instance of ModelInterface. Got $type"); | |
} | |
if ($offset === null) { | |
$this->members[] = $value; | |
} else { | |
$this->members[$offset] = $value; | |
} | |
} | |
public function offsetUnset($offset) | |
{ | |
unset($this->members[$offset]); | |
} | |
public function count() | |
{ | |
return count($this->members); | |
} | |
private function getTypeForError($value) : string | |
{ | |
if (is_object($value)) { | |
return get_class($value); | |
} else { | |
return gettype($value); | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment