Passed
Push — master ( febe3e...167352 )
by Sergei
06:51 queued 04:12
created

Sort::hasFieldInConfig()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

Changes 0
Metric Value
cc 1
eloc 1
nc 1
nop 1
dl 0
loc 3
ccs 2
cts 2
cp 1
crap 1
rs 10
c 0
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Data\Reader;
6
7
use InvalidArgumentException;
8
9
use function array_key_exists;
10
use function array_merge;
11
use function implode;
12
use function is_array;
13
use function is_int;
14
use function is_string;
15
use function preg_split;
16
use function substr;
17
use function trim;
18
19
/**
20
 * Sort represents data sorting settings:
21
 *
22
 * - A config with a map of logical field => real fields along with their order. The config also contains default
23
 *   order for each logical field.
24
 * - Currently specified logical fields order such as field1 => asc, field2 => desc. Usually it is passed directly
25
 *   from end user.
26
 *
27
 * Logical fields are the ones user operates with. Real fields are the ones actually present in a data set.
28
 * Such a mapping helps when you need to sort by a single logical field that, in fact, consists of multiple fields
29
 * in underlying the data set. For example, we provide a user with a username which consists of first name and last name
30
 * fields in actual data set.
31
 *
32
 * Based on the settings, the class can produce a criteria to be applied to {@see SortableDataInterface}
33
 * when obtaining the data i.e. a list of real fields along with their order directions.
34
 *
35
 * There are two modes of forming a criteria available:
36
 *
37
 * - {@see Sort::only()} ignores user-specified order for logical fields that have no configuration.
38
 * - {@see Sort::any()} uses user-specified logical field name and order directly for fields that have no configuration.
39
 *
40
 * @psalm-type TOrder = array<string, "asc"|"desc">
41
 * @psalm-type TSortFieldItem = array<string, int>
42
 * @psalm-type TConfigItem = array{asc: TSortFieldItem, desc: TSortFieldItem, default: "asc"|"desc"}
43
 * @psalm-type TConfig = array<string, TConfigItem>
44
 * @psalm-type TUserConfigItem = array{
45
 *     asc?: int|"asc"|"desc"|array<string, int|"asc"|"desc">,
46
 *     desc?: int|"asc"|"desc"|array<string, int|"asc"|"desc">,
47
 *     default?: "asc"|"desc"
48
 * }
49
 * @psalm-type TUserConfig = array<int, string>|array<string, TUserConfigItem>
50
 */
51
final class Sort
52
{
53
    /**
54
     * Logical fields config.
55
     *
56
     * @psalm-var TConfig
57
     */
58
    private array $config;
59
60
    /**
61
     * @var bool Whether to add default sorting when forming criteria.
62
     */
63
    private bool $withDefaultSorting = true;
64
65
    /**
66
     * @var array Logical fields to order by in form of [name => direction].
67
     * @psalm-var TOrder
68
     */
69
    private array $currentOrder = [];
70
71
    /**
72
     * @param array $config Logical fields config.
73
     * @psalm-param TUserConfig $config
74
     *
75
     * @param bool $ignoreExtraFields Whether to ignore logical fields not present in the config when forming criteria.
76
     */
77 109
    private function __construct(private bool $ignoreExtraFields, array $config)
78
    {
79 109
        $normalizedConfig = [];
80
81 109
        foreach ($config as $fieldName => $fieldConfig) {
82
            if (
83 103
                !(is_int($fieldName) && is_string($fieldConfig))
84 103
                && !(is_string($fieldName) && is_array($fieldConfig))
85
            ) {
86 2
                throw new InvalidArgumentException('Invalid config format.');
87
            }
88
89 101
            if (is_int($fieldName)) {
90
                /** @var string $fieldConfig */
91 94
                $fieldName = $fieldConfig;
92 94
                $fieldConfig = [];
93
            } else {
94
                /** @psalm-var TUserConfigItem $fieldConfig */
95 10
                foreach ($fieldConfig as $key => &$criteria) {
96
                    // 'default' => 'asc' or 'desc'
97 10
                    if ($key === 'default') {
98 8
                        continue;
99
                    }
100
                    // 'asc'/'desc' => SORT_*
101 10
                    if (is_int($criteria)) {
102 1
                        continue;
103
                    }
104
                    // 'asc'/'desc' => 'asc' or 'asc'/'desc' => 'desc'
105 10
                    if (is_string($criteria)) {
106 3
                        $criteria = [$fieldName => $criteria === 'desc' ? SORT_DESC : SORT_ASC];
107 3
                        continue;
108
                    }
109
                    // 'asc'/'desc' => ['field' => SORT_*|'asc'|'desc']
110 7
                    foreach ($criteria as &$subCriteria) {
111 7
                        if (is_string($subCriteria)) {
112 2
                            $subCriteria = $subCriteria === 'desc' ? SORT_DESC : SORT_ASC;
113
                        }
114
                    }
115
                }
116
            }
117
118 101
            $normalizedConfig[$fieldName] = array_merge(
119 101
                [
120 101
                    'asc' => [$fieldName => SORT_ASC],
121 101
                    'desc' => [$fieldName => SORT_DESC],
122 101
                    'default' => 'asc',
123 101
                ],
124 101
                $fieldConfig,
125 101
            );
126
        }
127
128
        /** @psalm-var TConfig $normalizedConfig */
129 107
        $this->config = $normalizedConfig;
130
    }
131
132
    /**
133
     * Create a sort instance that ignores current order for extra logical fields that have no configuration.
134
     *
135
     * @param array $config Logical fields config.
136
     * @psalm-param TUserConfig $config
137
     *
138
     * ```php
139
     * [
140
     *     'age', // means will be sorted as is
141
     *     'name' => [
142
     *         'asc' => ['first_name' => SORT_ASC, 'last_name' => SORT_ASC],
143
     *         'desc' => ['first_name' => SORT_DESC, 'last_name' => SORT_DESC],
144
     *         'default' => 'desc',
145
     *     ],
146
     * ]
147
     * ```
148
     *
149
     * In the above, two fields are declared: `age` and `name`. The `age` field is
150
     * a simple field which is equivalent to the following:
151
     *
152
     * ```php
153
     * 'age' => [
154
     *     'asc' => ['age' => SORT_ASC],
155
     *     'desc' => ['age' => SORT_DESC],
156
     *     'default' => 'asc',
157
     * ]
158
     * ```
159
     *
160
     * The name field is a virtual field name that consists of two real fields, `first_name` and `last_name`. Virtual
161
     * field name is used in order string or order array while real fields are used in final sorting criteria.
162
     *
163
     * Each configuration has the following options:
164
     *
165
     * - `asc` - criteria for ascending sorting.
166
     * - `desc` - criteria for descending sorting.
167
     * - `default` - default sorting. Could be either `asc` or `desc`. If not specified, `asc` is used.
168
     */
169 103
    public static function only(array $config): self
170
    {
171 103
        return new self(true, $config);
172
    }
173
174
    /**
175
     * Create a sort instance that uses logical field itself and direction provided when there is no configuration.
176
     *
177
     * @param array $config Logical fields config.
178
     * @psalm-param TUserConfig $config
179
     *
180
     * ```php
181
     * [
182
     *     'age', // means will be sorted as is
183
     *     'name' => [
184
     *         'asc' => ['first_name' => SORT_ASC, 'last_name' => SORT_ASC],
185
     *         'desc' => ['first_name' => SORT_DESC, 'last_name' => SORT_DESC],
186
     *         'default' => 'desc',
187
     *     ],
188
     * ]
189
     * ```
190
     *
191
     * In the above, two fields are declared: `age` and `name`. The `age` field is
192
     * a simple field which is equivalent to the following:
193
     *
194
     * ```php
195
     * 'age' => [
196
     *     'asc' => ['age' => SORT_ASC],
197
     *     'desc' => ['age' => SORT_DESC],
198
     *     'default' => 'asc',
199
     * ]
200
     * ```
201
     *
202
     * The name field is a virtual field name that consists of two real fields, `first_name` and `last_name`. Virtual
203
     * field name is used in order string or order array while real fields are used in final sorting criteria.
204
     *
205
     * Each configuration has the following options:
206
     *
207
     * - `asc` - criteria for ascending sorting.
208
     * - `desc` - criteria for descending sorting.
209
     * - `default` - default sorting. Could be either `asc` or `desc`. If not specified, `asc` is used.
210
     */
211 7
    public static function any(array $config = []): self
212
    {
213 7
        return new self(false, $config);
214
    }
215
216
    /**
217
     * Get a new instance with logical fields order set from an order string.
218
     *
219
     * The string consists of comma-separated field names.
220
     * If the name is prefixed with `-`, field order is descending.
221
     * Otherwise, the order is ascending.
222
     *
223
     * @param string $orderString Logical fields order as comma-separated string.
224
     *
225
     * @return self New instance.
226
     */
227 81
    public function withOrderString(string $orderString): self
228
    {
229 81
        $order = [];
230 81
        $parts = preg_split('/\s*,\s*/', trim($orderString), -1, PREG_SPLIT_NO_EMPTY);
231
232 81
        foreach ($parts as $part) {
233 81
            if (str_starts_with($part, '-')) {
234 20
                $order[substr($part, 1)] = 'desc';
235
            } else {
236 65
                $order[$part] = 'asc';
237
            }
238
        }
239
240 81
        return $this->withOrder($order);
241
    }
242
243
    /**
244
     * Return a new instance with logical fields order set.
245
     *
246
     * @param array $order A map with logical field names to order by as keys, direction as values.
247
     * @psalm-param TOrder $order
248
     *
249
     * @return self New instance.
250
     */
251 99
    public function withOrder(array $order): self
252
    {
253 99
        $new = clone $this;
254 99
        $new->currentOrder = $order;
255 99
        return $new;
256
    }
257
258
    /**
259
     * Return a new instance without default sorting set.
260
     *
261
     * @return self New instance.
262
     */
263 2
    public function withoutDefaultSorting(): self
264
    {
265 2
        $new = clone $this;
266 2
        $new->withDefaultSorting = false;
267 2
        return $new;
268
    }
269
270
    /**
271
     * Get current logical fields order.
272
     *
273
     * @return array Logical fields order.
274
     * @psalm-return TOrder
275
     */
276 81
    public function getOrder(): array
277
    {
278 81
        return $this->currentOrder;
279
    }
280
281
    /**
282
     * Get an order string based on current logical fields order.
283
     *
284
     * The string consists of comma-separated field names.
285
     * If the name is prefixed with `-`, field order is descending.
286
     * Otherwise, the order is ascending.
287
     *
288
     * @return string An order string.
289
     */
290 1
    public function getOrderAsString(): string
291
    {
292 1
        $parts = [];
293
294 1
        foreach ($this->currentOrder as $field => $direction) {
295 1
            $parts[] = ($direction === 'desc' ? '-' : '') . $field;
296
        }
297
298 1
        return implode(',', $parts);
299
    }
300
301
    /**
302
     * Get a sorting criteria to be applied to {@see SortableDataInterface}
303
     * when obtaining the data i.e. a list of real fields along with their order directions.
304
     *
305
     * @return array Sorting criteria.
306
     * @psalm-return array<string, int>
307
     */
308 92
    public function getCriteria(): array
309
    {
310 92
        $criteria = [];
311 92
        $config = $this->config;
312
313 92
        foreach ($this->currentOrder as $field => $direction) {
314 86
            if (array_key_exists($field, $config)) {
315 83
                $criteria = array_merge($criteria, $config[$field][$direction]);
316 83
                unset($config[$field]);
317
            } else {
318 6
                if ($this->ignoreExtraFields) {
319 3
                    continue;
320
                }
321 3
                $criteria = array_merge($criteria, [$field => $direction === 'desc' ? SORT_DESC : SORT_ASC]);
322
            }
323
        }
324
325 92
        if ($this->withDefaultSorting) {
326 91
            foreach ($config as $fieldConfig) {
327 36
                $criteria += $fieldConfig[$fieldConfig['default']];
328
            }
329
        }
330
331 92
        return $criteria;
332
    }
333
334
    /**
335
     * @param string $name The field name.
336
     *
337
     * @return bool Whether the field is present in the config.
338
     */
339 1
    public function hasFieldInConfig(string $name): bool
340
    {
341 1
        return isset($this->config[$name]);
342
    }
343
}
344