Issues

ZF-9516: Zend_Form_ArrayNotation

Description

The array notation within Zend_Form is very unstructured and cluttered all around the place.

It is not possible to retrieve the Array Notation Cascade for any Item before rendering because this is assembled within Zend_Form_Decorator_FormElements.

The methods getValues, setDefaults and similar are nearly unreadable because, (so it seems to me) elementsBelongTo and belongsTo where introduced later on and only partial patches addressed the need for these settings.

__

Zend_Form_ArrayNotation was a Singleton, :P which holds the structure of an endless number of Forms and appended Items by assigning attached Items an unique ident and splicing this into an one dimensional B-Tree like array, in a way that every ident will occure twice within this array where every idents between are childs of this ident.

Items are added by calling $notation->addItem($arrayProperties = array(), $intoIdent = null) which will return a new unique Ident after appending it into the given Ident or into root.

Items can be moved by calling $notation->appendItem($ident, $intoIdent) and can be removed by calling $notation->removeItem($ident) which will remove the whole item with childs from the tree, and also delete the properties.

Another array within Zend_Form_ArrayNotation holds properties for idents, currently 'name', 'path', 'concat', where 'path' is the belongsTo or elementsBelongTo setting of that item normalized to '/' separated segments. 'concat' is a keyword which method to use when appending a path to another or retrieving the Notation for an item, currently this is 'pathOnly', 'pathAppendName', 'emptyPathAppendName'. 'pathAppendName' would be used for Zend_Form_Element, and 'emptyPathAppendName' for Zend_Form.

These properties can be set at any time from everywhere to every item with calling $notation->setProperty($ident, $key, $value)

__

The Array Notation could be retrieved by calling $notation->getNotation($ident, $from, $concat) where $from is either an int for getting the notation up to $from parents, including itself (1 or -1 gets the parent notation appended the notation of the item itself), or an ident where getNotation will return the path starting from that $from ident (which is a parent of $ident) appended the notation of $ident.

With $concat it is possible to temporary change the method which is used to assemble the notation of $ident (This is needed for Zend_Form_Element which normaly uses 'pathAppendName' but in case of Zend_Form_Element->getBelongsTo() the name is not wanted in notation)

__

Zend_Form_ArrayNotation has several helper methods to process data related to Array Notation. $notation->getValuesForItem($ident, $values) currently looks if the notation of $ident could be dissolved within associative array $values and returns that dissolved value or null if it could not be dissolved. (This could be modified to dissolve for name of $ident also) $notation->getValuesForItemIfExist($ident, $values) does the same but returns unmodified $values if dissolve fails. $notation->appendValue($ident, $value) internally resolves the notation for $ident converts this to an array and attaches the value to this array. $notation->replaceInto($ident, $intoValue, $fromValue) is like array_replace_recursive($intoValue, $notation->appendValue($ident, $fromValue)) but with a custom array_replace_recursive function until 5.3 is widespreaded. $notation->toPath($ident | $stringNotation | $associativeArray) returns the path Notation with '/' separator (when using $ident, 'concat' method is used for this (like ->getNotation($ident,0)) $notation->toId($ident | $stringNotation | $associativeArray) does the same with separator '-' and when using $ident for the complete path from root to $ident.

__

This Class could handle all the belongsTo related settings and actions in one place.

It could be modified to allow custom Concat methods (call_user_func|closures) for each item which gets called when assembling the path|ArrayNotation.

It also could implement a way to name SubItems equally which makes them to an array of Items while still allowing overloading in Zend_Form.


...

    /**
     * @var Zend_Form_ArrayNotation 
     */
    protected $_notary;

    /**
     * unique ident from Zend_Form_ArrayNotation
     * @var string
     */
    protected $_ident;

...    

    public function __construct($options = null)
    {
        $this->_notary = Zend_Form_ArrayNotation::getInstance();
        $this->_ident  = $this->_notary->addItem(array('concat' => 'emptyPathAppendName'));

...

    /**
     * Get the ArrayNotation instance 
     * 
     * @return Zend_From_ArrayNotation
     */
    public function getNotary()
    {
        return $this->_notary;
    }

    /**
     * Get the unique Ident from Zend_Form_ArrayNotation 
     * 
     * @return string
     */
    public function getIdent()
    {
        return $this->_ident;
    }

...

    public function setElementsBelongTo($array)
    {
        $path = $this->filterName($array, true);
        $this->setIsArray('' !== $path);
        $this->getNotary()->setPath($this->getIdent(), $path);

        return $this;
    }

...

    public function getElementsBelongTo()
    {
        return $this->getNotary()->getNotation($this->getIdent(), 0);
    }

...

    public function addSubForm(Zend_Form $form, $name, $order = null)
    {

...

        $this->getNotary()->appendItem($form->getIdent(), $this->getIdent());
        return $this;
    }

...

    public function getValidValues($data, $suppressArrayNotation = false)
    {
        $notary = $this->getNotary();
        $data   = $notary->getValuesForItemIfExist($this->getIdent(), $data);

        $values = array();
        foreach ($this->_getElementsAndSubFormsOrdered() as $subitem) {
            $ident = $subitem->getIdent();
            if ($subitem instanceof Zend_Form_Element) {
                $test  = $notary->getValuesForItem($ident, $data);
                if (null !== $test) {
                    if ($subitem->isValid($test, $data)) {
                        $values = $notary->replaceInto($ident, $values,
                                                       $subitem->getValue());
                    }
                }
            } else {
                $values = $notary->replaceInto($ident, $values,
                                               $subitem->getValidValues($data, true));
            }
        }
        if (!$suppressArrayNotation && $this->isArray()) {
            $values = $notary->appendValue($this->getIdent(), $values);
        }
        return $values;
    }

...

$map = Zend_Form_ArrayNotation::getInstance();
$form1 = $map->addItem(array("name" => "form1Name",
                             "path" => "path/to/form1[mixed][Notation]",
                             "concat" => "emptyPathAppendName"));
var_dump($form1);

string '#1' (length=2)


$element1 = $map->addItem(array("name" => "element1Name",
                                "path" => "path1[withArrayNotation]",
                                "concat" => "pathAppendName"),
                                $form1);

$subform1 = $map->addItem(array("name" => "subform1Name",
                                "path" => "subform1Path[withArrayNotation]",
                                "concat" => "emptyPathAppendName"),
                                $form1);
$element2 = $map->addItem(array("name" => "element2Name",
                                "path" => "path1[withArrayNotation]",
                                "concat" => "pathAppendName"),
                                $subform1);
$element3 = $map->addItem(array("name" => "element3Name",
                                "path" => "path1[withArrayNotation]",
                                "concat" => "pathAppendName"),
                                $subform1);
$element4 = $map->addItem(array("name" => "element4Name",
                                "path" => "path1[withArrayNotation]",
                                "concat" => "pathAppendName"),
                                $form1);

var_dump($map->debugItemsArrays());

array
  '$this->_array' => 
    array
      0 => string '#0' (length=2)
      1 => string '#1' (length=2)
      2 => string '#2' (length=2)
      3 => string '#2' (length=2)
      4 => string '#3' (length=2)
      5 => string '#4' (length=2)
      6 => string '#4' (length=2)
      7 => string '#5' (length=2)
      8 => string '#5' (length=2)
      9 => string '#3' (length=2)
      10 => string '#6' (length=2)
      11 => string '#6' (length=2)
      12 => string '#1' (length=2)
      13 => string '#0' (length=2)
  '$this->_items' => 
    array
      '#0' => 
        array
          empty
      '#1' => 
        array
          'name' => string 'form1Name' (length=9)
          'path' => string 'path/to/form1/mixed/Notation' (length=28)
          'concat' => string '_concatEmptyPathAppendName' (length=26)
      '#2' => 
        array
          'name' => string 'element1Name' (length=12)
          'path' => string 'path1/withArrayNotation' (length=23)
          'concat' => string '_concatPathAppendName' (length=21)
      '#3' => 
        array
          'name' => string 'subform1Name' (length=12)
          'path' => string 'subform1Path/withArrayNotation' (length=30)
          'concat' => string '_concatEmptyPathAppendName' (length=26)
      '#4' => 
        array
          'name' => string 'element2Name' (length=12)
          'path' => string 'path1/withArrayNotation' (length=23)
          'concat' => string '_concatPathAppendName' (length=21)
      '#5' => 
        array
          'name' => string 'element3Name' (length=12)
          'path' => string 'path1/withArrayNotation' (length=23)
          'concat' => string '_concatPathAppendName' (length=21)
      '#6' => 
        array
          'name' => string 'element4Name' (length=12)
          'path' => string 'path1/withArrayNotation' (length=23)
          'concat' => string '_concatPathAppendName' (length=21)



var_dump($map->getNotation($element2, 0, "pathOnly"));

string 'path1[withArrayNotation]' (length=24)


var_dump($map->getNotation($element2, 0));

string 'path1[withArrayNotation][element2Name]' (length=38)


var_dump($map->getNotation($element2, -1));

string 'subform1Path[withArrayNotation][path1][withArrayNotation][element2Name]' (length=71)


var_dump($map->getNotation($element2, $subform1));

string 'subform1Path[withArrayNotation][path1][withArrayNotation][element2Name]' (length=71)


var_dump($map->getNotation($element2));

string 'path[to][form1][mixed][Notation][subform1Path][withArrayNotation][path1][withArrayNotation][element2Name]' (length=105)


var_dump($map->getNotation($element2, -2));

string 'path[to][form1][mixed][Notation][subform1Path][withArrayNotation][path1][withArrayNotation][element2Name]' (length=105)


var_dump($map->getNotation($element2, "#0"));

string 'path[to][form1][mixed][Notation][subform1Path][withArrayNotation][path1][withArrayNotation][element2Name]' (length=105)


var_dump($map->getNotation($element2, null, "pathOnly"));

string 'path[to][form1][mixed][Notation][subform1Path][withArrayNotation][path1][withArrayNotation]' (length=91)


$map->setName($element2, null);

var_dump($map->getValuesForItem($element2, array("path1" => array("withArrayNotation" => "thisIWant"))));

string 'thisIWant' (length=9)


$map->setName($element2, "element2");
$map->setPath($element2, null);
var_dump($map->getValuesForItem($element2, array("element2" => array("NowThis" => "withArray"))));

array
  'NowThis' => string 'withArray' (length=17)


var_dump($map->getValuesForItemIfExist($element2, array("nonexistant" => array("notation" => "passthrou"))));

array
  'nonexistant' => 
    array
      'notation' => string 'passthrou' (length=9)

$map->setPath($element2, "new[path][forElement2]");

var_dump($value = $map->appendValue($element2, array("valueFor" => "Element2")));

array
  'new' => 
    array
      'path' => 
        array
          'forElement2' => 
            array
              'element2' => 
                array
                  'valueFor' => string 'Element2' (length=8)


var_dump($map->replaceInto($element2, $value, array("valueFor" => "NewValue",
                                                    "newValueFor" => "Element2")));

array
  'new' => 
    array
      'path' => 
        array
          'forElement2' => 
            array
              'element2' => 
                array
                  'valueFor' => string 'NewValue' (length=8)
                  'newValueFor' => string 'Element2' (length=8)

Comments

I read somewhere in the Tracker "no more Singletons", so i refactor the Class to use Zend_Registry now.

Another Approach using Zend_Registry, well, this is untested though.

Updated AnotherApproach_Notary, this is really exciting for me, as everything fits together plus it is complex but not complicated.

This is the new Approach, the description above is a bit outdated now, but the consept is similar


<?php

class Zend_Form_Notary
{
    protected $_registryName = 'FormNotary';

    protected $_array;
    protected $_items;
    protected $_btree;
    protected $_index;

    protected $_ident;

    public function __construct()
    {
        if (!Zend_Registry::isRegistered($this->_registryName)) {
            $this->_setupArray();
        }
        $this->_loadArray()
             ->_generateIdent();
    }

    protected function _setupArray()
    {
        $this->_array = array('items' => array('#0' => array()),
                              'btree' => array('#0', '#0'),
                              'index' => array());

        return $this->_saveArray();
    }    

    protected function _generateIdent()
    {
        end($this->_items);
        $this->_ident = '#' . (ltrim(key($this->_items), '#') + 1);
        $this->_array['items'][$this->_ident] = array();
        return $this;
    }

    protected function _loadArray()
    {
        $this->_array = Zend_Registry::get($this->_registryName);
        $this->_items =& $this->_array['items'];
        $this->_btree =& $this->_array['btree'];
        $this->_index =& $this->_array['index'];
        return $this;
    }

    protected function _saveArray()
    {
        Zend_Registry::set($this->_registryName, $this->_array);
        return $this;
    }

    public function getIdent()
    {
        return $this->_ident;
    }

    public function isIdent($ident)
    {
        $this->_loadArray();
        if (array_key_exists($ident, $this->_items)) {
            return true;
        }
        return false;
    }

    protected function _ensureIdent($ident)
    {
        if (null === $ident) {
            $this->_loadArray();
            return $this->_ident;
        }
        if (!$this->isIdent($ident)) {
            throw new Zend_Form_Exception("Nonexistant Ident '$ident'");
        }
        return $ident;
    }

    public function setProperty($key, $value, $forIdent = null)
    {
        $this->_setProperty($key, $value, $this->_ensureIdent($forIdent));
        $this->_saveArray();
    }

    public function setProperties($properties, $forIdent = null)
    {
        $ident = $this->_ensureIdent($forIdent);
        foreach ($properties as $key => $value) {
            $this->_setProperty($key, $value, $ident);
        }
        $this->_saveArray();
    }

    protected function _setProperty($key, $value, $ident)
    {
        if (3 == func_num_args()) {
            $value = $this->normalize($key, $value);
        }
        $this->_items[$ident][$key] = $value;
        if (!isset($this->_index[$key])) {
            $this->_index[$key] = array();
        }
        $this->_index[$key][$ident] =& $this->_items[$ident][$key];
        return $this;
    }

    public function getProperty($key, $fromIdent = null)
    {
        $key   = (string)$key;
        $ident = $this->_ensureIdent($fromIdent);
        return $this->prepare($key, $this->_getProperties($key, $ident), null);
    }

    public function getProperties($keys = null, $fromIdent = null)
    {
        $array = array();
        $ident = $this->_ensureIdent($fromIdent);
        foreach ($this->_getProperties($keys, $ident, true) as $key => $val) {
            $array[$key] = $this->prepare($key, $val, null);
        }
        return $array;
    }

    protected function _getProperties($key, $ident, $returnArray = false)
    {
        $keys = $this->_filterPropertyKeys($key);
        if (!empty($keys)) {
            if (true === $returnArray) {
                return array_intersect_key($this->_items[$ident], $keys);
            } else {
                return array_shift(array_intersect_key($this->_items[$ident],
                                                       $keys)));
            }
        } 
        return (true === $returnArray ? array() : null);
    }

    protected function _filterPropertyKeys($keys)
    {
        $ret = array();
        if (null === $key) {
            $ret = array_keys($this->_index);
        } else if (is_scalar($keys)) {
            $ret = array_intersect_key($this->_index, array($keys));
        } else if (is_array($keys)) {
            $ret = array_intersect_key($this->_index, $keys);
        }
        return $ret;
    }

    public function prepare($key, $value)
    {
        $prepare = 'prepare' . ucfirst($key);
        if (method_exists($this, $prepare)) {
            switch (func_num_args()) {
                case 3 :
                    return $this->$prepare($value, null);
                    break;
                default :
                    return $this->$prepare($value);
                    break;
            }
        }
        return $value;
    }

    public function prepareNotation($notation)
    {
        if (1 == func_num_args()) {
            $notation = $this->normalizeNotation($notation);
        }
        if (empty($segments = explode('/', $notation))) {
            return strtr($notation, '#', '');
        }
        $notation = array_shift($segments);
        if (count($segments)) {
            $notation .= '[' . join('][', $segments) . ']';
        }
        return strtr($notation, '#', '');
    }

    public function normalize($key, $value)
    {
        $normalize = 'normalize' . ucfirst($key);
        if (method_exists($this, $normalize)) {
            return $this->$normalize($value);
        }
        return $value;
    }

    public function normalizeNotation($notation)
    {
        $notation = str_replace('[]','/#/', $notation);
        return trim(strtr($notation, array('[' => '/', ']' => '')), '/');
    }

    public function getChilds($parent = null)
    {
        $parent = $this->_ensureIdent($parent);
        list($l, $r) = array_keys($this->_btree, $parent);
        $offsprings  = array_slice($this->_btree, $l+1, $r-$l);

        $childs = array();
        $child  = null;

        while (false !== ($current = current($offsprings))) {
            if (null === $child) {
                $childs[] = $child = $current;
            } else if ($child === $current) {
                $child = null;
            }
            next($offsprings);
        }
        if (empty($childs)) {
            return null;
        }
        return $childs;
    }

    public function getChildsByProperty($value, $filterKey = null, $fromIdent = null)
    {
        return $this->_filterIdents((array)$this->getChilds($fromIdent),
                                    $filterKey, $value);
    }

    public function getAncestors($ancestor = '#0', $offspring = null, $slice = null)
    {
        $offspring = $this->_ensureIdent($offspring);

        $idents = array();
        if ($this->isIdent($ancestor)) {
            $idents = $this->_ancestorsSeek($ancestor, $offspring);
        }
        if (!empty($idents)) {
            if (null !== $slice) {
                return array_slice($idents, $slice);
            }
            return $idents;
        }
        return null;
    }

    protected function _ancestorsSeek($ancestor, $offspring)
    {
        // outer bounds
        list($ol, $or) = array_keys($this->_btree, $ancestor, true);
        // inner bounds
        list($il, $ir) = array_keys($this->_btree, $offspring, true);

        return array_intersect(array_slice($this->_btree, $ol, $il-$ol+1),
                               array_slice($this->_btree, $ir, $or-$ir+1));
    }

    public function getIdentsByProperty($value, $filterKey = null)
    {
        $this->_loadArray();
        return $this->_filterIdents(array_keys($this->_items),
                                    $filterKey, $value);
    }

    protected function _filterIdents($idents, $keys, $value)
    {
        $keys  = $this->_filterPropertyKeys($keys);
        $array = array();
        foreach ($idents as $ident) {
            foreach (array_intersect_key($this->_items[$ident], $keys) as $pk => $pv) {
                if ($this->equals($pk, $pv, $value)) {
                    $array[] = $ident;
                }
            }
        }
        if (empty($array)) {
            return null;
        }
        return $array;
    }

    public function equals($propKey, $propVal, $value)
    {
        $equal = 'equals' . ucfirst($propKey);
        if (method_exists($this, $equal)) {
            return $this->$equal($propVal, $value);
        }
        return ($propVal == $value);
    }

    public function equalsNotation($str1, $str2)
    {
        $str1 = rtrim($this->normalizeNotation($str1), '/#');
        $str2 = rtrim($this->normalizeNotation($str2), '/#');
        return ($str1 === $str2);
    }
}