Fluent Arrays and Strings in PHP


I’ve been working some with jQuery and Ruby lately, as you might know they both have very neat fluent interfaces for writing short and easily understandable code. Especially Ruby’s array and string handling should be something that can be done in PHP so I started googling for something nice but so far the best I’ve been able to find is pooQuery. Update: Apparently pooQuery really is a joke (see the comments at the bottom of this post), anyway here is an example from it of how fluent stuff can go too far:

take('Hello World')->and->call('substr')->with(array(it(), 0, 5))
        ->and->take(call()->result)->and->display();

It’s probably very possible that what I’m looking for is already part of some PHP framework or such but I didn’t want to spend more time looking than being productive. So back to the drawing board with Ruby as the inspiration with a little help from Lisp (I’ve “stolen” function names from both), take for instance this example from the wxRuby editor:

keywords = arr.find_all{|wrd|  
      wrd.downcase.index(sub_str) == 0 && wrd.downcase != sub_str 
    }.uniq.sort.join(" ").strip

At the moment we don’t have something like the block functionality demonstrated above in PHP. It will never be as clean because we have have to resort to writing our code as a string one way or the other with all the special headaches that comes with it. However the typical usage of a block is some simple test to filter with, just like in the above example and in such cases we can do pretty good with PHP. Here is the PHP equivalent:

$arr = array('Apple', 'Applepie', 'Appletart', 'apples', 'apples', 'orangepie', 'orangeapple', 'purple', 'red', 'applemousse');
Arr::fluent($arr)->collect(
		"(strripos('??', 'apple') === 0 && strtolower('??') != 'apple')"
	)->uniq()->sort()->join()->trim()->out();

Output:

Applepie Appletart applemousse apples

2D example:

$arr = array(
	array('name' => 'John', 'age' => 65), 
	array('name' => 'Mary', 'age' => 32), 
	array('name' => 'Jonny', 'age' => 45));
Arr::fluent2d($arr)->collect(
			"strripos('??', 'jo') !== false", false, 'name'
		)->normal2d()->out();

Output:

Array
(
    [0] => Array
        (
            [name] => John
            [age] => 65
        )

    [2] => Array
        (
            [name] => Jonny
            [age] => 45
        )

)

Not too bad. What I’ve done is basically wrapping all the array (as Arr) and string (as Str) functions I use frequently in a fluent interface, 80% of the code is very straightforward and self evident, some functions like collect() above requires a little explaining though. Basically all the “magic” in this little library is accomplished with eval(), func_get_args() and call_user_func_array(). Note the use of place holders in the form of ‘??’, the reason we don’t use ‘?’ is because ? is an operand in some expressions.

Let’s take a look at the code:

function collect($expr, $doKey = false, $key = false){
  if($this->d == 1) 	return $this->common_eval($expr, true, $doKey);
  else			    return $this->common_2d('collect', $expr, $doKey, $key);
}

private function common_2d($f, $expr, $doKey, $key){
  $old 		 = $this->normal2d(true);
  $doKey 	= $doKey == false ? 'false' : 'true';
  $new 		= $this->apply2d($f, "\"$expr\"", $doKey)->compact($key)->normal2d(true);
  $this->c 	= $old;
  $this->make2d();
  return $this->isectKey($new);
}

private function common_eval($expr, $bool2, $doKey = false){
  foreach($this->c as $key => $a){
    $tmp 	 = $doKey == false ? $a : $key;
    $replace 	= ($a instanceof Str && !$doKey) ? $a->c : $tmp;
    eval($this->fix_expr($expr, $replace));
    if($result == $bool2) $rarr[$key] = $a;
  }
  return $this->ret($rarr);
}

private function fix_expr($expr, $replace){ 
  return "\$result = ".str_replace('??', $replace, $expr).";"; 
}

The dimensionality of the array is flagged with $this->d which means that the library is working with either normal arrays or tables (sub arrays have same size), no hybrids or higher dimensions thanks. Luckily hybrids are rare in real life (at least in my life). $doKey flags if we are going to work with the keys (true) or the values (false). If the array is two dimensional we also have to pass $key to let the code know which column we want to work with. In the above case we work with the names.

Let’s start with the 1D example, it’s less complex. We will pass the expression to work with, a boolean that is used to either keep or delete whatever we manage to match, del() is a synonym for passing false, collect will of course pass true since we want to keep the matches. In both the examples above $doKey is false, we work with the values. We continue with looping through the containter, $this->c. If an element ($a) in turn is a fluent object we need to be able to handle this and work with its container, hence the instanceof Str test, Str is the name of our fluent string class. Finally we evaluate, and as you can see the result of the evaluation will be stored in $result which will be available to the evaluating code, if the result is true we keep the value.

The 2D example is more complex, we now make use of apply2d() and compact() too:

function apply2d(){
  $params 	= func_get_args();
  $f 		    = array_shift($params);
  foreach($this->c as &$a){
    $param_str = implode(',', $params);
    eval("\$a = \$a->$f($param_str);");
  }
  return $this;	
}

function compact($onKey = false){
  foreach($this->c as $key => $a){
    $tmp = $this->d == 1 ? $a : $a->c;
    $tmp = $onKey === false ? $tmp : $tmp[$onKey];
    if(!empty($tmp)) 
      $rarr[$key] = $a;
  }
  return $this->ret($rarr);
}

Due to the fact that apply2d() works with the container (c) as a reference we need to store the initial state of the container before we start working with it - in order to be able to later compare the before and after states. normal2d() will per default convert the current container to a normal 2D array, not an array of fluent Arr objects. If passed true it will return the 2D array instead which is what we are doing here.

The contents of $doKey needs to be passed as a string since they will later be evaluated as parts of one, hence the second line in the common_2d function. The new resultant array will be stored in $new, the current container will now also have been changed and we restore everything by setting it to the old value we stored in the beginning of the function followed by calling make2d() which will convert the container from a normal 2D array to an array of Arr objects, we are now completely back to the original state and have the new array in $new.

That’s why we can use isectKey() to get rid of all the entries of our container that do not belong in the $new array:

function isectKey($arr){ 
  $this->c = array_intersect_key($this->c, $arr); return $this; 
}

In all our manipulations we preserve the keys of the resultant arrays, hence the manipulation with array_intersect_key.

The need to go from string to array and/or back will frequently arise, for instance when unserializing/serializing and splitting/joining. There are more complex functions though that can be used when we have a fluent array filled with fluent strings, we can then walk the array and apply a certain method to each member:

$arr = array('banana-split', 'apple-pie');
Arr::fluent($arr)->makeStrs()->strApply('split', "'-'")->apply2d('join')->normalStrs()->out();
}

Output:

Array
(
    [0] => banana split
    [1] => apple pie
)

Here we first create a fluent array of course, then we turn each member in the array into Str instances, then we apply the Str method split() to each string, that will give us a fluent array containing an array of fluent arrays which we can apply the Arr method join() to. The result will be a fluent array containing fluent Strings, we’re back where we started, finally we apply normalStrs() to get a fluent array filled with normal strings. Let’s look at strApply:

function strApply(){
  $params 	= func_get_args();
  $f 			= array_shift($params);
  $param_str 	= implode(',',$params);
  foreach($this->c as &$a){
    if($a instanceof Str)
      eval("\$a = \$a->$f($param_str);");
  }
  return $this;
}

Very similar to apply2d, except we have to check each member for compatibility with instanceof in case we have a mixed array.

Another method that checks for compatibility is setIf() which as its name implies will set all values that matches an expression to something else:

$arr = array(new Str('banana'), 'banana', 'orange');
Arr::fluent($arr)->setIf("'??' == 'banana'", 'apple' )->out();

Will output:

Array
(
    [0] => Str Object
        (
            [c] => apple
        )

    [1] => apple
    [2] => orange
)

Again we check with instanceof :

function setIf($expr, $assign){
  foreach($this->c as &$a){
    $str_q 	= $a instanceof Str;
    $tmp 	= $str_q ? $a->c : $a;
    eval($this->fix_expr($expr, $tmp));
    if($result){
      if($str_q) 	$a->c 	= $assign;
      else		 $a 	   = $assign;
    }
  }
  return $this;
}

If we have a Str we use the container instead. Let’s take a look at sorting:

$arr = array(
	array('name' => 'John', 'age' => 65), 
	array('name' => 'Mary', 'age' => 32), 
	array('name' => 'Alfred', 'age' => 45));
Arr::fluent2d($arr)->serial()->unserial()->make2d()
	->sort('desc', 'age')->normal2d()->out();

Will output:

Array
(
    [0] => Array
        (
            [name] => John
            [age] => 65
        )

    [1] => Array
        (
            [name] => Alfred
            [age] => 45
        )

    [2] => Array
        (
            [name] => Mary
            [age] => 32
        )
)

The sort() method is kind of messy and based on what was arrived at in the sorting 2d arrays in php post:

function sort($flags = null, $key = null){
  if($this->d == 1){
    switch($flags){
      case'num': 	$flag = SORT_NUMERIC; break;
      case'string': 	 $flag = SORT_STRING; break;
      default: 		  $flag = SORT_REGULAR; break;
    }
    asort($this->c, $flag); 
    return $this;
  }else{
    foreach($this->c as $sub) $s_col[] = $sub->c[$key];
    $s_type   = is_string($s_col[0]) ? SORT_STRING : SORT_NUMERIC;
    $arr 	= $this->normal2d()->c;
    $flag 	= $flags == 'desc' ? SORT_DESC : SORT_ASC;
    if($s_type == SORT_STRING) $s_col = array_map('strtolower', $s_col);
    array_multisort($s_col, $s_type, $flag, $arr);
    return self::fluent2d($arr);
  }
}

As you can see, the $flags argument can be ‘desc’ or ‘asc’ to sort in a descending or ascending fashion - if we are sorting a 2d array ($this->d is 2), then we also need a column name to sort by, in this case ‘age’. Talking about array_map, let’s check the map() method:

Arr::fluent2d($arr)->map('strtolower')->flatten('name')->sort()->out();

Will output

Array
(
    [2] => alfred
    [0] => john
    [1] => mary
)

if we use the same array as above.

function map($f = null){
  if($this->d == 1){
    $this->c = array_map($f, $this->c);
    return $this;
  }else
    return $this->apply2d('map', "'$f'"); 
}

Note again the use of apply2d if we have a 2d array, note also the stuff we have to do in order for things to evaluate properly in the context of a string sent to eval(), in this case it’s wrapping $f with ‘ ‘.

Let’s move over to stuff specific to the fluent string, let’s begin with format():

$str = "There are %d monkeys in a %s in %s";
Str::fluent($str)->format('10', 'tree', 'Thailand')->out();

Will output:

There are 10 monkeys in a tree in Thailand
function format(){
  $params 	= func_get_args();
  $call_arr 	  = array_merge(array($this->c), $params);
  $this->c 	 = call_user_func_array('sprintf', $call_arr);
  return $this;
}

Not much to note here, we simply prepare our data what we need to pass to call_user_func_array.

More complex stuff is regular expressions which basically has two shortcuts, match() and matchPos() for simple use cases of matchArb() (Arb as in Arbitrary). Let’s take a look at all of them:

$str = "applepie applemousse cucumber appletart";
Str::fluent($str)->matchPos('/apple\w+/')->out();
Str::fluent($str)->match('/apple\w+/')->sort()->join()->out();
$str = "fruit apple fruit orange color purple fruit pineapple color red";
Str::fluent($str)->matchArb('/(fruit)\s(\w+)/', PREG_SET_ORDER|PREG_OFFSET_CAPTURE, 0, 2)
	->make2d()->flatten()->join(';')->out();

Output:

Array
(
    [0] => 0
    [1] => 9
    [2] => 30
)

applemousse applepie appletart

apple;6;orange;18;pineapple;44
private function match_common($pattern, $flag, $offset, $pos = false){
  preg_match_all($pattern, $this->c, $matches, $flag, $offset);
  if($flag == PREG_OFFSET_CAPTURE) 	foreach($matches[0] as $match) 	$rarr[] = $match[1];
  else if($flag == PREG_PATTERN_ORDER) 	foreach($matches[0] as $match) 	$rarr[] = $match;
  else						        foreach($matches as $match) 	$rarr[] = $match[$pos];
  return new Arr($rarr);
}

function match($pattern, $offset = 0){ 
  return $this->match_common($pattern, PREG_PATTERN_ORDER, $offset); 
}

function matchPos($pattern, $offset = 0){ 
  return $this->match_common($pattern, PREG_OFFSET_CAPTURE, $offset); 
}

function matchArb($pattern, $flag, $offset, $pos){ 
  return $this->match_common($pattern, $flag, $offset, $pos); 
}

So when we use matchArb we pass something else than PREG_OFFSET_CAPTURE or PREG_PATTERN_ORDER. In that case we need to also pass the position/key we want to fetch in the resultant $matches array. In our example it’s 2.

The replace method is easier to implement:

$str = "applepie applemousse cucumber appletart";
Str::fluent($str)->matchReplace('/(apple)(\w+)/', 'orange${2}')->split()->sort()->join()->out();

Output:

cucumber orangemousse orangepie orangetart

Code:

function matchReplace($pattern, $replacement, $limit = -1){
  $this->c 		= preg_replace($pattern, $replacement, $this->c, $limit, $count);
  $this->count 	      = $count;
  return $this;
}

Finally we have apply() which can be used to run a function not implemented in the interface yet (if you are too lazy to implement):

$arr1 = array('apple', 'orange', 'purple');
$arr2 = array('fruit1', 'fruit2', 'color1');
Arr::fluent($arr1)->apply('array_combine', $arr2, '?')->sort()->out();

$str = "apple orange apple guava lime durian";
echo Str::fluent($str)->apply('r', 'substr_count', '?', 'apple');
}

Output:

Array
(
    [fruit1] => apple
    [fruit2] => orange
    [color1] => purple
)

2

The method is defined in FluentExt which both Arr and Str inherits from, note the switch case statement that controls how we return. In the string case above we pass ‘r’ so we return the result instead of a new Str instance. As you probably have realized already the ‘?’ will be replaced with the current container, in this case we don’t need ‘??’, to my knowledge none of the global array and string functions can have ‘?’ as one of their arguments (let me know if they can).

private function fill_place_holder(&$arr){
  foreach($arr as &$a)
    if($a == '?') $a = $this->c;
}

function apply(){
  $params 	= func_get_args();
  $this		->fill_place_holder($params);
  $el 		= array_shift($params);
  if(strlen($el) == 1){
    $ret 	= $el;
    $f 		= array_shift($params);
  }else{
    $f 		= $el;
    $ret	= false;
  }
  switch($ret){
    case 'r':
      return call_user_func_array($f, $params);
      break;
    default:
      $this->c = call_user_func_array($f, $params);
      return $this;
      break;
  }
}

There is one more thing that I will leave up to you to explore how it works, here is the test though:

$arr1 = array('apple', 'orange', 'purple');
$arr2 = array('fruit1', 'fruit2', 'color1');
$arr3 = array($arr1, $arr1);
Arr::fluent2d($arr3)->apply2sub('array_combine', $arr2, '?')
	->normal2d()->out();

Output:

Array
(
    [0] => Array
        (
            [fruit1] => apple
            [fruit2] => orange
            [color1] => purple
        )

    [1] => Array
        (
            [fruit1] => apple
            [fruit2] => orange
            [color1] => purple
        )
)

Download Source

And that’s it, check the source if you are unsure about some things, I’m happy anyway, I finally have my string and array interface. A final note though, the code is optimized for size and programmer friendliness, not the machine, you probably don’t want to use this stuff for arrays with thousands of entries, especially the 2D stuff. Note also that what is not tested in test.php has not been tested, it might or might not work.

Related Posts

Tags: , , ,