3

I start with a nested array of some arbitrary depth. Within that array, some keys are a series of tokens separated by dots. For example "billingAddress.street" or "foo.bar.baz". I would like to expand those keyed elements to arrays, so the result is a nested array with all those keys expanded.

For example:

[
    'billingAddress.street' => 'My Street',
    'foo.bar.baz' => 'biz',
]

should be expanded to:

[
    'billingAddress' => [
        'street' => 'My Street',
    ],
    'foo' => [
        'bar' => [
            'baz' => 'biz',
        ]
    ]
]

The original "billingAddress.street" can be left alongside the new "billingAddress" array, but it does not need to be (so the solution may operate on the original array or create a new array). Other elements such as "billingAddress.city" may need to be added to the same expanded portion of the array.

Some keys may have more than two tokens separated by dots, so will need to be expanded deeper.

I've looked at array_walk_recursive() but that only operates on elements. For each matching element key, I actually want to modify the parent array those elements are in.

I've looked at array_map, but that does not provide access to the keys, and as far as I know is not recursive.

An example array to expand:

[
    'name' => 'Name',
    'address.city' => 'City',
    'address.street' => 'Street',
    'card' => [
        'type' => 'visa',
        'details.last4' => '1234',
    ],
]

This is to be expanded to:

[
    'name' => 'Name',
    'address.city' => 'City', // Optional
    'address' => [
        'city' => 'City',
        'street' => 'Street',
    ],
    'address.street' => 'Street', // Optional
    'card' => [
        'type' => 'visa',
        'details.last4' => '1234', // Optional
        'details' => [
            'last4' => '1234',
        ],
    ],
]

What I think I need, is something that walks to each array in the nested array and can apply a user function to it. But I do suspect I'm missing something obvious. The payment gateway I am working with sends me this mix of arrays and "pretend arrays" using the dot-notation, and my objective is to normalize it into an array for extracting portions.

I believe the problem differs from similar questions on SO due to this mix of arrays and non-arrays for expanding. Conceptually it is a nested array where sound groups of elements at any level need to be replaced with new arrays, so there are two levels of recursion happening here: the tree walking, and the expansion, and then the walking of the expanded trees to see if there is more expansion needed.

Jason
  • 2,506
  • 4
  • 31
  • 43
  • Are we guaranteed maximum one `.` or is it possible we'll see `foo.bar.baz`? If the latter, how should it be handled--as `baz` child of `bar` or sibling? – ggorlen Jul 28 '18 at 16:27
  • 1
    Possible duplicate of [String with array structure to Array](https://stackoverflow.com/questions/8537148/string-with-array-structure-to-array) – Nigel Ren Jul 28 '18 at 16:51
  • @NigelRen the sub-structures are scattered over an existing nested array, so this is about expanding nodes in the array rather than the mechanism of building an array element from scratch. The answers have been most illuminating, with offered solutions tackling the problem in a new way to me. – Jason Jul 29 '18 at 10:56

3 Answers3

5

You could find it useful to reverse the order of the keys you get from exploding the combined (dotted) key. In that reversed order it is easier to progressively wrap a previous result into a new array, thereby creating the nested result for one dotted key/value pair.

Finally, that partial result can be merged into the accumulated "grand" result with the built-in array_merge_recursive function:

function expandKeys($arr) {
    $result = [];
    foreach($arr as $key => $value) {
        if (is_array($value)) $value = expandKeys($value);
        foreach(array_reverse(explode(".", $key)) as $key) $value = [$key => $value];
        $result = array_merge_recursive($result, $value);
    }
    return $result;
}

See it run on repl.it

trincot
  • 211,288
  • 25
  • 175
  • 211
  • This is great! I'm realizing, though, that issues arise with keys like `'foo.bar' => [ 'avocado' ], 'foo.bar.baz' => [ 'banana' ]` which makes it a little annoying. I'm not sure how robust OP needs this to be with such edge cases. – ggorlen Jul 28 '18 at 19:37
  • @ggorlen, thanks for your comment. As far as I can see both your and my solution produce the same result for the edge case you mention, and I think it is the most appropriate result as it keeps both "avocado" and "banana" in the output. In the OP's own solution the "avocado" value gets lost. – trincot Jul 28 '18 at 20:56
  • It's still there but indexed to a key of `0`, so it seems like new lists need to be created with the proper key. Here's another scenario: `foo.bar.baz => "banana", foo.bar.baz => "avocado"` where `"avocado"` will overwrite `"banana"`. I was messing with it further but gave up for now :) – ggorlen Jul 28 '18 at 21:00
  • 1
    I would not worry about the last example: they (i.e. `foo.bar.baz`) are keys, and so they should be unique by definition. It is the same with standard PHP array literals: `["key" => 1, "key" => 4]` will produce an array with 1 key/value pair. There is no real problem there. For the case we were discussing first: `["avocado"]` is really short notation for `["0" => "avocado"]`, so it seems perfectly fine to me that the result has a `0` key. – trincot Jul 28 '18 at 21:19
  • This is a great solution - `array_merge_recursive()` is a really underrated function. Your example avocado edge-case won't happen here - if there is a `foo.bar.baz` then `foo.bar` will always represent an array in the source data. The source data is consistent like this, but goes through this implosion of sorts as it comes to me over the API. – Jason Jul 28 '18 at 22:04
  • @trincot I guess I'm thinking of the dotted keys as paths to values rather than keys per se, and one key can hold an array of values. In other words, same paths resolve to arrays. But you're right and I buy your argument! Second scenario, I feel that `foo.bar => "avocado"` should eventually resolve to `"bar" => "avocado"` rather than `"0" => "avocado"`. Regardless, I see @Jason is happy with the solution(s) so I am too. Fun problem. – ggorlen Jul 28 '18 at 22:04
  • Trying to understand how this works. Is this approach constructing from the leaf nodes towards the root, while my solution builds from the root up to the leaf nodes? The results seem to be the same either way. – Jason Jul 28 '18 at 22:51
  • Indeed, @Jason, it is like you say. – trincot Jul 29 '18 at 12:38
  • @ggorlen, there is a difference here: `"foo.bar" => "avocado"` *does* resolve to `"foo" => ["bar" => "avocado"]`. It's only when the value is an indexed array that the result reuses that index (i.e. "0"). So ``"foo.bar" => ["avocado"]` (with the brackets) resolves to `"foo" => ["bar" => ["0" => "avocado"]]`. In other words, if the value is an array, its keys represent a part of a path, regardless whether it is a (maybe implicit) numerical key or an (explicit) string key. – trincot Jul 29 '18 at 12:44
4

Here's a recursive attempt. Note that this doesn't delete old keys, doesn't maintain any key ordering and ignores keys of the type foo.bar.baz.

function expand(&$data) {
  if (is_array($data)) {
    foreach ($data as $k => $v) {
      $e = explode(".", $k);

      if (count($e) == 2) {
        [$a, $b] = $e;
        $data[$a][$b]= $v;
      }

      expand($data[$k]);
    }
  }
}

Result:

Array
(
    [name] => Name
    [address.city] => City
    [address.street] => Street
    [card] => Array
        (
            [type] => visa
            [details.last4] => 1234
            [details] => Array
                (
                    [last4] => 1234
                )

        )

    [address] => Array
        (
            [city] => City
            [street] => Street
        )

)

Explanation:

On any call of the function, if the parameter is an array, iterate through the keys and values looking for keys with a . in them. For any such keys, expand them out. Recursively call this function on all keys in the array.

Full version:

Here's a full version that supports multiple .s and cleans up keys afterwards:

function expand(&$data) {
  if (is_array($data)) {
    foreach ($data as $k => $v) {
      $e = explode(".", $k);
      $a = array_shift($e);

      if (count($e) == 1) {
        $data[$a][$e[0]] = $v;
      }
      else if (count($e) > 1) {
        $data[$a][implode(".", $e)] = $v;
      }
    }

    foreach ($data as $k => $v) {
      expand($data[$k]);

      if (preg_match('/\./', $k)) {
        unset($data[$k]);
      }
    }
  }
}
ggorlen
  • 26,337
  • 5
  • 34
  • 50
  • Thanks. `foo.bar.baz` would need to be handled too. I'll have a play with this, maybe to return a new array rather than modifying the original. I think what I was missing, was that I needed a function that could call itself, as I was hoping a standard PHP function could do the recursive stuff for me. – Jason Jul 28 '18 at 17:03
  • No problem, but see my comment on your question--does `foo.bar.baz` expand as a series of children or as siblings? – ggorlen Jul 28 '18 at 17:06
  • A series of children `['foo']['bar'] => 'baz'` – Jason Jul 28 '18 at 17:20
  • Sounds good. It's helpful if you update your post with a concrete example of expected i/o since this is a pretty important constraint. – ggorlen Jul 28 '18 at 17:25
  • Okay, I think I have an answer now. Instead of the `foreach` there to loop through the elements, I treat it like a stack. Any element that get expanded to an array by virtue of its key, gets added to the end of the stack, so it then goes through the same expansion as its siblings. That handles the `foo.bar.baz` to any depth, by processing one level at a time then recersing into that to expand the next level. I'll post the details after tidying up. – Jason Jul 28 '18 at 17:34
  • 1
    Sure, sounds good. The other thing I was thinking of is any nested children can be appended in string format so that they're automatically processed when the depth is reached. E.g. `foo.bar.baz` creates a key called `foo` in the current scope and appends a child key called `bar.baz` with the appropriate value. Next recursive call will unpack that. – ggorlen Jul 28 '18 at 17:43
2

Another solution by @trincot has been accepted as being more elegant, and is the solution I am using now.

Here is my solution, which expands on the solution and tips given by @ggorlen

The approach I have taken is:

  • Create a new array rather than operate on the initial array.
  • No need to keep the old pre-expanded elements. They can be added easily if needed.
  • Expanding the keys is done one level at a time, from the root array, with the remaining expansions passed back in recursively.

The class method:

protected function expandKeys($arr)
{
    $result = [];

    while (count($arr)) {
        // Shift the first element off the array - both key and value.
        // We are treating this like a stack of elements to work through,
        // and some new elements may be added to the stack as we go.

        $value = reset($arr);
        $key = key($arr);
        unset($arr[$key]);

        if (strpos($key, '.') !== false) {
            list($base, $ext) = explode('.', $key, 2);

            if (! array_key_exists($base, $arr)) {
                // This will be another array element on the end of the
                // arr stack, to recurse into.

                $arr[$base] = [];
            }

            // Add the value nested one level in.
            // Value at $arr['bar.baz.biz'] is now at $arr['bar']['baz.biz']
            // We may also add to this element before we get to processing it,
            // for example $arr['bar.baz.bam']


            $arr[$base][$ext] = $value;
        } elseif (is_array($value)) {
            // We already have an array value, so give the value
            // the same treatment in case any keys need expanding further.

            $result[$key] = $this->expandKeys($value);
        } else {
            // A scalar value with no expandable key.

            $result[$key] = $value;
        }
    }

    return $result;
}

$result = $this->expandKeys($sourceArray)
Jason
  • 2,506
  • 4
  • 31
  • 43