Skip to content

Instantly share code, notes, and snippets.

@stecman
Last active August 28, 2021 23:41
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save stecman/f74b70f5e549f6c73b5baa4154bc7caa to your computer and use it in GitHub Desktop.
Save stecman/f74b70f5e549f6c73b5baa4154bc7caa to your computer and use it in GitHub Desktop.
Phalcon PHP modifiable array assignment of relationships on new objects
<?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);
}
}
<?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