Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.43% covered (success)
96.43%
54 / 56
50.00% covered (danger)
50.00%
2 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
ArrayPathSetter
96.43% covered (success)
96.43%
54 / 56
50.00% covered (danger)
50.00%
2 / 4
25
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 transform
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 replace
96.67% covered (success)
96.67%
29 / 30
0.00% covered (danger)
0.00%
0 / 1
15
 getCompiledReplacements
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
6
1<?php
2
3namespace CirrusSearch\Profile;
4
5use Wikimedia\Assert\Assert;
6
7/**
8 * Transforms arrays based on replacement variable using a syntax
9 * to lookup the entry to modify.
10 *
11 * Examples:
12 *  [ 'elem1.elem2' => 'value' ]
13 * will replace the following profile
14 *  [ 'elem1' =>
15 *      [
16 *             'elem2' => 'placeholder'
17 *      ]
18 *  ]
19 * with :
20 *  [ 'elem1' =>
21 *      [
22 *             'elem2' => 'value'
23 *      ]
24 *  ]
25 *
26 * The syntax supports lookaheads on next array value:
27 *  [ 'elem1.*[type=bar].element' => [ 'custom' => 'data' ] ]
28 * will replace the following profile
29 *  [ 'elem1' =>
30 *      [
31 *             [
32 *                 'type' => 'foo'
33 *                 'element' => []
34 *             ],
35 *             [
36 *                 'type' => 'bar'
37 *                 'element' => []
38 *             ],
39 *      ]
40 *  ]
41 * with :
42 *  [ 'elem1' =>
43 *      [
44 *             [
45 *                 'type' => 'foo'
46 *                 'element' => []
47 *             ],
48 *             [
49 *                 'type' => 'bar'
50 *                 'element' => [ 'custom' => 'data' ]
51 *             ],
52 *      ]
53 *  ]
54 *
55 * A single substition can occur, the first match wins.
56 * The full path must always be matched.
57 */
58class ArrayPathSetter {
59    private const PATTERN = '/\G(?:(?<!^)[.]|^)(?<keypath>[^.\[\]]+)(?:\[(?<lookaheadkey>[^.\[\]]+)=(?<lookaheadvalue>[^.\[\]]+)\])?/';
60    /**
61     * @var mixed[]
62     */
63    private $replacements;
64
65    /**
66     * Lazily initializaed after calling getCompiledReplacements
67     * @var array|null list of replacements compiled after appyling preg_match_all
68     */
69    private $compiledReplacements;
70
71    /**
72     * @param mixed[] $replacements array of replacements indexed with a string in the syntax supported
73     * by this class
74     */
75    public function __construct( array $replacements ) {
76        $this->replacements = $replacements;
77    }
78
79    /**
80     * Transform the profile
81     * @param array|null $profile
82     * @return array|null
83     */
84    public function transform( ?array $profile = null ) {
85        if ( $profile === null ) {
86            return null;
87        }
88        foreach ( $this->getCompiledReplacements() as $replacement ) {
89            $profile = $this->replace( $profile, $replacement );
90        }
91        return $profile;
92    }
93
94    /**
95     * @param array $profile
96     * @param array $replacement
97     * @return array transformed profile
98     */
99    private function replace( array $profile, array $replacement ) {
100        $cur = &$profile;
101        foreach ( $replacement['matches'] as $match ) {
102            if ( !is_array( $cur ) ) {
103                return $profile;
104            }
105            $keypath = $match['keypath'][0];
106            $keys = [ $keypath ];
107            if ( $keypath === '*' && isset( $match['lookaheadvalue'] ) && $match['lookaheadvalue'][0] !== '' ) {
108                $keys = array_keys( $cur );
109            }
110            $found = false;
111            foreach ( $keys as $key ) {
112                if ( !array_key_exists( $key, $cur ) ) {
113                    continue;
114                }
115                $maybeNext = $cur[$key];
116                if ( is_array( $maybeNext ) && isset( $match['lookaheadvalue'] )
117                     && $match['lookaheadvalue'][0] !== ''
118                ) {
119                    $lookaheadKey = $match['lookaheadkey'][0];
120                    $lookaheadValue = $match['lookaheadvalue'][0];
121                    if ( isset( $maybeNext[$lookaheadKey] ) ) {
122                        $lookahead = $maybeNext[$lookaheadKey];
123                        // Use == instead of === so that a pattern expression such as [boost=2]
124                        // can match a profile declaring [ 'boost' => 2.0 ]
125                        if ( !is_array( $lookahead ) && $lookahead == $lookaheadValue ) {
126                            $cur = &$cur[$key];
127                            $found = true;
128                            break;
129                        }
130                    }
131                } else {
132                    $cur = &$cur[$key];
133                    $found = true;
134                    break;
135                }
136            }
137            if ( !$found ) {
138                return $profile;
139            }
140        }
141        $cur = $replacement['value'];
142        return $profile;
143    }
144
145    /**
146     * Compile the replacement strings if needed
147     * @return array
148     */
149    private function getCompiledReplacements() {
150        if ( $this->compiledReplacements === null ) {
151            $this->compiledReplacements = [];
152            foreach ( $this->replacements as $repl => $value ) {
153                if ( !is_string( $repl ) ) {
154                    throw new SearchProfileException( "Replacement pattern must be string but is a " . get_debug_type( $repl ) );
155                }
156                $matches = [];
157                $ret = preg_match_all( self::PATTERN, $repl, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE );
158                Assert::postcondition( $ret !== false, 'preg_match_all should not fail' );
159                if ( $matches !== [] ) {
160                    $lastMatch = end( $matches )[0];
161                    // Make sure that we matched the whole input if not it means we perhaps matched
162                    // the beginning but some components at the end
163                    $valid = ( strlen( $lastMatch[0] ) + $lastMatch[1] ) === strlen( $repl );
164                    reset( $matches );
165                } else {
166                    $valid = false;
167                }
168                if ( !$valid ) {
169                    throw new SearchProfileException( "Invalid replacement pattern provided: [$repl]." );
170                }
171                $this->compiledReplacements[] = [
172                    'value' => $value,
173                    'matches' => $matches
174                ];
175            }
176        }
177        return $this->compiledReplacements;
178    }
179}