Issues (25)

src/ArrayableTrait.php (4 issues)

1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Arrays;
6
7
use function array_key_exists;
8
use function in_array;
9
use function is_array;
10
use function is_int;
11
use function is_string;
12
13
/**
14
 * `ArrayableTrait` provides a common implementation of the {@see ArrayableInterface} interface.
15
 *
16
 * `ArrayableTrait` implements {@see ArrayableInterface::toArray()} by respecting the field definitions as declared
17
 * in {@see ArrayableInterface::fields()} and {@see ArrayableInterface::extraFields()}.
18
 */
19
trait ArrayableTrait
20
{
21
    /**
22
     * Returns the list of fields that should be returned by default by {@see ArrayableInterface::toArray()}
23
     * when no specific fields are specified.
24
     *
25
     * A field is a named element in the returned array by {@see ArrayableInterface::toArray()}.
26
     *
27
     * This method should return an array of field names or field definitions.
28
     * If the former, the field name will be treated as an object property name whose value will be used
29
     * as the field value. If the latter, the array key should be the field name while the array value should be
30
     * the corresponding field definition which can be either an object property name or a PHP callable
31
     * returning the corresponding field value. The signature of the callable should be:
32
     *
33
     * ```php
34
     * function ($model, $field) {
35
     *     // return field value
36
     * }
37
     * ```
38
     *
39
     * For example, the following code declares four fields:
40
     *
41
     * - `email`: the field name is the same as the property name `email`;
42
     * - `firstName` and `lastName`: the field names are `firstName` and `lastName`, and their
43
     *   values are obtained from the `first_name` and `last_name` properties;
44
     * - `fullName`: the field name is `fullName`. Its value is obtained by concatenating `first_name`
45
     *   and `last_name`.
46
     *
47
     * ```php
48
     * return [
49
     *     'email',
50
     *     'firstName' => 'first_name',
51
     *     'lastName' => 'last_name',
52
     *     'fullName' => function () {
53
     *         return $this->first_name . ' ' . $this->last_name;
54
     *     },
55
     * ];
56
     * ```
57
     *
58
     * In this method, you may also want to return different lists of fields based on some context
59
     * information. For example, depending on the privilege of the current application user,
60
     * you may return different sets of visible fields or filter out some fields.
61
     *
62
     * The default implementation of this method returns the public object member variables indexed by themselves.
63
     *
64
     * @return array The list of field names or field definitions.
65
     *
66
     * @see toArray()
67
     */
68 3
    public function fields(): array
69
    {
70 3
        $fields = array_keys(ArrayHelper::getObjectVars($this));
71 3
        return array_combine($fields, $fields);
72
    }
73
74
    /**
75
     * Returns the list of fields that can be expanded further and returned by {@see ArrayableInterface::toArray()}.
76
     *
77
     * This method is similar to {@see ArrayableInterface::fields()} except that the list of fields returned
78
     * by this method are not returned by default by {@see ArrayableInterface::toArray()}]. Only when field names
79
     * to be expanded are explicitly specified when calling {@see ArrayableInterface::toArray()}, will their values
80
     * be exported.
81
     *
82
     * The default implementation returns an empty array.
83
     *
84
     * You may override this method to return a list of expandable fields based on some context information
85
     * (e.g. the current application user).
86
     *
87
     * @return array The list of expandable field names or field definitions. Please refer
88
     * to {@see ArrayableInterface::fields()} on the format of the return value.
89
     *
90
     * @see toArray()
91
     * @see fields()
92
     */
93 1
    public function extraFields(): array
94
    {
95 1
        return [];
96
    }
97
98
    /**
99
     * Converts the model into an array.
100
     *
101
     * This method will first identify which fields to be included in the resulting array
102
     * by calling {@see resolveFields()}. It will then turn the model into an array with these fields.
103
     * If `$recursive` is true, any embedded objects will also be converted into arrays.
104
     * When embedded objects are {@see ArrayableInterface}, their respective nested fields
105
     * will be extracted and passed to {@see ArrayableInterface::toArray()}.
106
     *
107
     * @param array $fields The fields being requested.
108
     * If empty or if it contains '*', all fields as specified by {@see ArrayableInterface::fields()} will be returned.
109
     * Fields can be nested, separated with dots (.). e.g.: item.field.sub-field
110
     * `$recursive` must be true for nested fields to be extracted. If `$recursive` is false, only the root fields
111
     * will be extracted.
112
     * @param array $expand The additional fields being requested for exporting. Only fields declared
113
     * in {@see ArrayableInterface::extraFields()} will be considered.
114
     * Expand can also be nested, separated with dots (.). e.g.: item.expand1.expand2
115
     * `$recursive` must be true for nested expands to be extracted. If `$recursive` is false, only the root expands
116
     * will be extracted.
117
     * @param bool $recursive Whether to recursively return array representation of embedded objects.
118
     *
119
     * @return array The array representation of the object.
120
     */
121 4
    public function toArray(array $fields = [], array $expand = [], bool $recursive = true): array
122
    {
123 4
        $data = [];
124 4
        foreach ($this->resolveFields($fields, $expand) as $field => $definition) {
125 4
            $attribute = is_string($definition) ? $this->$definition : $definition($this, $field);
126
127 4
            if ($recursive) {
128 4
                $nestedFields = $this->extractFieldsFor($fields, $field);
129 4
                $nestedExpand = $this->extractFieldsFor($expand, $field);
130 4
                if ($attribute instanceof ArrayableInterface) {
131 1
                    $attribute = $attribute->toArray($nestedFields, $nestedExpand);
132 4
                } elseif (is_array($attribute) && ($nestedExpand || $nestedFields)) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $nestedExpand of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
Bug Best Practice introduced by
The expression $nestedFields of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
133 1
                    $attribute = $this->filterAndExpand($attribute, $nestedFields, $nestedExpand);
134
                }
135
            }
136 4
            $data[$field] = $attribute;
137
        }
138
139 4
        return $recursive ? ArrayHelper::toArray($data) : $data;
140
    }
141
142 1
    private function filterAndExpand(array $array, array $fields = [], array $expand = []): array
143
    {
144 1
        $data = [];
145 1
        $rootFields = $this->extractRootFields($fields);
146 1
        $rootExpand = $this->extractRootFields($expand);
147 1
        foreach (array_merge($rootFields, $rootExpand) as $field) {
148 1
            if (array_key_exists($field, $array)) {
149 1
                $attribute = $array[$field];
150 1
                $nestedFields = $this->extractFieldsFor($fields, $field);
151 1
                $nestedExpand = $this->extractFieldsFor($expand, $field);
152 1
                if ($attribute instanceof ArrayableInterface) {
153 1
                    $attribute = $attribute->toArray($nestedFields, $nestedExpand);
154 1
                } elseif (is_array($attribute) && ($nestedExpand || $nestedFields)) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $nestedFields of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
Bug Best Practice introduced by
The expression $nestedExpand of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
155 1
                    $attribute = $this->filterAndExpand($attribute, $nestedFields, $nestedExpand);
156
                }
157 1
                $data[$field] = $attribute;
158
            }
159
        }
160 1
        return $data;
161
    }
162
163
    /**
164
     * Extracts the root field names from nested fields.
165
     * Nested fields are separated with dots (.). e.g: "item.id"
166
     * The previous example would extract "item".
167
     *
168
     * @param array $fields The fields requested for extraction
169
     *
170
     * @return array root Fields extracted from the given nested fields.
171
     */
172 4
    protected function extractRootFields(array $fields): array
173
    {
174 4
        $result = [];
175
176 4
        foreach ($fields as $field) {
177 1
            $result[] = strstr($field . '.', '.', true);
178
        }
179
180 4
        if (in_array('*', $result, true)) {
181 1
            $result = [];
182
        }
183
184 4
        return array_unique($result);
185
    }
186
187
    /**
188
     * Extract nested fields from a fields collection for a given root field
189
     * Nested fields are separated with dots (.). e.g: "item.id"
190
     * The previous example would extract "id".
191
     *
192
     * @param array $fields The fields requested for extraction.
193
     * @param string $rootField The root field for which we want to extract the nested fields.
194
     *
195
     * @return array Nested fields extracted for the given field.
196
     */
197 4
    protected function extractFieldsFor(array $fields, string $rootField): array
198
    {
199 4
        $result = [];
200
201 4
        foreach ($fields as $field) {
202 1
            if (str_starts_with($field, "{$rootField}.")) {
203 1
                $result[] = preg_replace('/^' . preg_quote($rootField, '/') . '\./i', '', $field);
204
            }
205
        }
206
207 4
        return array_unique($result);
208
    }
209
210
    /**
211
     * Determines which fields can be returned by {@see ArrayableInterface::toArray()}.
212
     * This method will first extract the root fields from the given fields.
213
     * Then it will check the requested root fields against those declared in {@see ArrayableInterface::fields()}
214
     * and {@see ArrayableInterface::extraFields()} to determine which fields can be returned.
215
     *
216
     * @param array $fields The fields being requested for exporting.
217
     * @param array $expand The additional fields being requested for exporting.
218
     *
219
     * @return array The list of fields to be exported. The array keys are the field names, and the array values
220
     * are the corresponding object property names or PHP callables returning the field values.
221
     */
222 4
    protected function resolveFields(array $fields, array $expand): array
223
    {
224 4
        $fields = $this->extractRootFields($fields);
225 4
        $expand = $this->extractRootFields($expand);
226 4
        $result = [];
227
228 4
        foreach ($this->fields() as $field => $definition) {
229 4
            if (is_int($field)) {
230 3
                $field = $definition;
231
            }
232 4
            if (empty($fields) || in_array($field, $fields, true)) {
233 4
                $result[$field] = $definition;
234
            }
235
        }
236
237 4
        if (empty($expand)) {
238 4
            return $result;
239
        }
240
241 1
        foreach ($this->extraFields() as $field => $definition) {
242 1
            if (is_int($field)) {
243 1
                $field = $definition;
244
            }
245 1
            if (in_array($field, $expand, true)) {
246 1
                $result[$field] = $definition;
247
            }
248
        }
249
250 1
        return $result;
251
    }
252
}
253