Passed
Push — hans/code-cleaning ( 4e53b3...375cb6 )
by Simon
04:40
created

FieldResolver::findOrigin()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 17
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 10
CRAP Score 3

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 3
eloc 9
c 1
b 0
f 0
nc 3
nop 5
dl 0
loc 17
ccs 10
cts 10
cp 1
crap 3
rs 9.9666
1
<?php
2
/**
3
 * class FieldResolver|Firesphere\SolrSearch\Helpers\FieldResolver Identify fields or relations in a DataObject
4
 *
5
 * @package Firesphere\SolrSearch\Helpers
6
 * @author Simon `Firesphere` Erkelens; Marco `Sheepy` Hermo
7
 * @copyright Copyright (c) 2018 - now() Firesphere & Sheepy
8
 */
9
10
namespace Firesphere\SolrSearch\Helpers;
11
12
use Exception;
13
use Firesphere\SolrSearch\Traits\GetSetSearchResolverTrait;
14
use ReflectionException;
15
use SilverStripe\Core\ClassInfo;
16
use SilverStripe\ORM\DataObject;
17
use SilverStripe\ORM\DataObjectSchema;
18
19
/**
20
 * Class FieldResolver
21
 * Some additional introspection tools that are used often by the fulltext search code
22
 *
23
 * @package Firesphere\SolrSearch\Helpers
24
 */
25
class FieldResolver
26
{
27
    use GetSetSearchResolverTrait;
28
    /**
29
     * @var array Class Ancestry
30
     */
31
    protected static $ancestry = [];
32
    /**
33
     * @var array Class Hierarchy, could be replaced with Ancestry
34
     */
35
    protected static $hierarchy = [];
36
37
    /**
38
     * Check if class is subclass of (a) the class in $instanceOf, or (b) any of the classes in the array $instanceOf
39
     *
40
     * @param string $class Name of the class to test
41
     * @param array|string $instanceOf Class ancestry it should be in
42
     * @return bool
43
     * @static
44
     */
45 1
    public static function isSubclassOf($class, $instanceOf): bool
46
    {
47 1
        $ancestry = self::$ancestry[$class] ?? self::$ancestry[$class] = ClassInfo::ancestry($class);
48
49 1
        if (is_array($instanceOf)) {
50 1
            return (bool)array_intersect($instanceOf, $ancestry);
51
        }
52
53
        return array_key_exists($instanceOf, $ancestry);
54
    }
55
56
    /**
57
     * Resolve a field ancestry
58
     *
59
     * @param $field
60
     * @return array
61
     * @throws Exception
62
     *
63
     */
64 39
    public function resolveField($field)
65
    {
66 39
        $fullfield = str_replace('.', '_', $field);
67
68 39
        $buildSources = $this->getBuildSources();
69
70 39
        $found = [];
71
72 39
        if (strpos($field, '.') !== false) {
73 39
            $lookups = explode('.', $field);
74 39
            $field = array_pop($lookups);
75
76 39
            foreach ($lookups as $lookup) {
77 39
                $buildSources = $this->getNext($buildSources, $lookup);
78
            }
79
        }
80
81 39
        $found = $this->getFieldOptions($field, $buildSources, $fullfield, $found);
82
83 39
        return $found;
84
    }
85
86
    /**
87
     * Get the sources to build in to a Solr field
88
     *
89
     * @return array
90
     */
91 39
    protected function getBuildSources(): array
92
    {
93 39
        $sources = $this->index->getClasses();
94 39
        $buildSources = [];
95
96 39
        $schemaHelper = DataObject::getSchema();
97 39
        foreach ($sources as $source) {
98 39
            $buildSources[$source]['base'] = $schemaHelper->baseDataClass($source);
99
        }
100
101 39
        return $buildSources;
102
    }
103
104
    /**
105
     * Get the next lookup item from the buildSources
106
     *
107
     * @param array $buildSources
108
     * @param $lookup
109
     * @return array
110
     * @throws Exception
111
     */
112 39
    protected function getNext(array $buildSources, $lookup): array
113
    {
114 39
        $next = [];
115
116
        // @todo remove repetition
117 39
        foreach ($buildSources as $source => $baseOptions) {
118 39
            $next = $this->resolveRelation($source, $lookup, $next, $baseOptions);
119
        }
120
121 39
        $buildSources = $next;
122
123 39
        return $buildSources;
124
    }
125
126
    /**
127
     * Resolve relations if possible
128
     *
129
     * @param string $source
130
     * @param $lookup
131
     * @param array $next
132
     * @param array $options
133
     * @return array
134
     * @throws ReflectionException
135
     * @throws Exception
136
     */
137 39
    protected function resolveRelation($source, $lookup, array $next, array &$options): array
138
    {
139 39
        $source = $this->getSourceName($source);
140
141 39
        foreach (self::getHierarchy($source) as $dataClass) {
142 39
            $schema = DataObject::getSchema();
143 39
            $options['multi_valued'] = false;
144
145 39
            $class = $this->getRelationData($lookup, $schema, $dataClass, $options);
146
147 39
            if (is_string($class) && $class) {
148 39
                if (!isset($options['origin'])) {
149 39
                    $options['origin'] = $source;
150
                }
151
152
                // we add suffix here to prevent the relation to be overwritten by other instances
153
                // all sources lookups must clean the source name before reading it via getSourceName()
154 39
                $next[$class . '|xkcd|' . $dataClass] = $options;
155
            }
156
        }
157
158 39
        return $next;
159
    }
160
161
    /**
162
     * This is used to clean the source name from suffix
163
     * suffixes are needed to support multiple relations with the same name on different page types
164
     *
165
     * @param string $source
166
     * @return string
167
     */
168 39
    private function getSourceName($source)
169
    {
170 39
        $explodedSource = explode('|xkcd|', $source);
171
172 39
        return $explodedSource[0];
173
    }
174
175
    /**
176
     * Get all the classes involved in a DataObject hierarchy - both super and optionally subclasses
177
     *
178
     * @static
179
     * @param string $class - The class to query
180
     * @param bool $dataOnly - True to only return classes that have tables
181
     * @return array - Integer keys, String values as classes sorted by depth (most super first)
182
     * @throws ReflectionException
183
     */
184 83
    public static function getHierarchy($class, $dataOnly = false): array
185
    {
186
        // Generate the unique key for this class and it's call type
187
        // It's a short-lived cache key for the duration of the request
188 83
        $cacheKey = sprintf('%s-sc-%s', $class, $dataOnly ? 'do' : 'al');
189
190 83
        if (!isset(self::$hierarchy[$cacheKey])) {
191 5
            $classes = self::getHierarchyClasses($class);
192
193 5
            if ($dataOnly) {
194 1
                $classes = array_filter($classes, static function ($class) {
195 1
                    return DataObject::getSchema()->classHasTable($class);
196 1
                });
197
            }
198
199 5
            self::$hierarchy[$cacheKey] = array_values($classes);
200
201 5
            return array_values($classes);
202
        }
203
204 83
        return self::$hierarchy[$cacheKey];
205
    }
206
207
    /**
208
     * Get the hierarchy for a class
209
     *
210
     * @param $class
211
     * @return array
212
     * @throws ReflectionException
213
     * @todo clean this up to be more compatible with PHP features
214
     */
215 5
    protected static function getHierarchyClasses($class): array
216
    {
217 5
        if (!isset(self::$ancestry[$class])) {
218 4
            self::$ancestry[$class] = array_values(ClassInfo::ancestry($class));
219
        }
220 5
        $ancestry = self::$ancestry[$class];
221
222 5
        $classes = self::getSubClasses($class, $ancestry);
223
224 5
        $classes = array_unique($classes);
225 5
        $classes = self::excludeDataObjectIDx($classes);
226
227 5
        return $classes;
228
    }
229
230
    /**
231
     * Get the subclasses for the given class
232
     * Should be replaced with PHP native methods
233
     *
234
     * @param $class
235
     * @param array $classes
236
     * @return array
237
     * @throws ReflectionException
238
     */
239 5
    private static function getSubClasses($class, array $classes): array
240
    {
241 5
        $subClasses = ClassInfo::subclassesFor($class);
242 5
        $classes = array_merge($classes, array_values($subClasses));
243
244 5
        return $classes;
245
    }
246
247
    /**
248
     * Objects to exclude from the index
249
     *
250
     * @param array $classes
251
     * @return array
252
     */
253 5
    private static function excludeDataObjectIDx(array $classes): array
254
    {
255
        // Remove all classes below DataObject from the list
256 5
        $idx = array_search(DataObject::class, $classes, true);
257 5
        if ($idx !== false) {
258 5
            array_splice($classes, 0, $idx + 1);
259
        }
260
261 5
        return $classes;
262
    }
263
264
    /**
265
     * Relational data
266
     *
267
     * @param $lookup
268
     * @param DataObjectSchema $schema
269
     * @param $className
270
     * @param array $options
271
     * @return string|array|null
272
     * @throws Exception
273
     */
274 39
    protected function getRelationData($lookup, DataObjectSchema $schema, $className, array &$options)
275
    {
276 39
        if ($hasOne = $schema->hasOneComponent($className, $lookup)) {
277 39
            return $hasOne;
278
        }
279 39
        $options['multi_valued'] = true;
280 39
        if ($hasMany = $schema->hasManyComponent($className, $lookup)) {
281 39
            return $hasMany;
282
        }
283 39
        if ($key = $schema->manyManyComponent($className, $lookup)) {
284
            return $key['childClass'];
285
        }
286
287 39
        return null;
288
    }
289
290
    /**
291
     * Create field options for the given index field
292
     *
293
     * @param $field
294
     * @param array $sources
295
     * @param string $fullfield
296
     * @param array $found
297
     * @return array
298
     * @throws ReflectionException
299
     */
300 39
    protected function getFieldOptions($field, array $sources, $fullfield, array $found): array
301
    {
302 39
        foreach ($sources as $class => $fieldOptions) {
303 39
            $found = $this->findOrigin($field, $fullfield, $found, $class, $fieldOptions);
304
        }
305
306 39
        return $found;
307
    }
308
309
    /**
310
     * Get the type of this field
311
     *
312
     * @param array $fields
313
     * @param string $field
314
     * @param string $dataclass
315
     * @return string
316
     */
317 39
    protected function getType($fields, $field, $dataclass): string
318
    {
319 39
        if (!empty($fields[$field])) {
320 39
            return $fields[$field];
321
        }
322
323
        /** @var DataObject $singleton */
324 38
        $singleton = singleton($dataclass);
325
326 38
        $type = $singleton->castingClass($field);
327
328 38
        if (!$type) {
329
            $type = 'String';
330
        }
331
332 38
        return $type;
333
    }
334
335
    /**
336
     * FoundOriginData is a helper to make sure the options are properly set.
337
     *
338
     * @param string $field
339
     * @param string $fullField
340
     * @param array $fieldOptions
341
     * @param string $dataclass
342
     * @param string $type
343
     * @param array $found
344
     * @return array
345
     */
346 39
    private function getFoundOriginData(
347
        $field,
348
        $fullField,
349
        $fieldOptions,
350
        $dataclass,
351
        $type,
352
        $found
353
    ): array {
354
        // Get the origin
355 39
        $origin = $fieldOptions['origin'] ?? $dataclass;
356
357 39
        $found["{$origin}_{$fullField}"] = [
358 39
            'name'         => "{$origin}_{$fullField}",
359 39
            'field'        => $field,
360 39
            'fullfield'    => $fullField,
361 39
            'origin'       => $origin,
362 39
            'class'        => $dataclass,
363 39
            'type'         => $type,
364 39
            'multi_valued' => isset($fieldOptions['multi_valued']) ? true : false,
365
        ];
366
367 39
        return $found;
368
    }
369
370
    /**
371
     * @param $field
372
     * @param $fullfield
373
     * @param array $found
374
     * @param $class
375
     * @param $fieldOptions
376
     * @return array
377
     * @throws ReflectionException
378
     */
379 39
    protected function findOrigin($field, $fullfield, array $found, $class, $fieldOptions): array
380
    {
381 39
        $class = $this->getSourceName($class);
382 39
        $dataclasses = self::getHierarchy($class);
383
384 39
        $fields = DataObject::getSchema()->databaseFields($class);
385 39
        while ($dataclass = array_shift($dataclasses)) {
386 39
            $type = $this->getType($fields, $field, $dataclass);
387
388 39
            if ($type) {
389
                // Don't search through child classes of a class we matched on.
390 39
                $dataclasses = array_diff($dataclasses, array_values(ClassInfo::subclassesFor($dataclass)));
391 39
                $found = $this->getOriginForType($field, $fullfield, $found, $fieldOptions, $dataclass, $type);
392
            }
393
        }
394
395 39
        return $found;
396
    }
397
398
    /**
399
     * @param $field
400
     * @param $fullfield
401
     * @param array $found
402
     * @param $fieldOptions
403
     * @param $dataclass
404
     * @param string $type
405
     * @return array
406
     */
407 39
    protected function getOriginForType(
408
        $field,
409
        $fullfield,
410
        array $found,
411
        $fieldOptions,
412
        $dataclass,
413
        string $type
414
    ): array {
415
        // Trim arguments off the type string
416 39
        if (preg_match('/^(\w+)\(/', $type, $match)) {
417 39
            $type = $match[1];
418
        }
419
420 39
        $found = $this->getFoundOriginData($field, $fullfield, $fieldOptions, $dataclass, $type, $found);
421
422 39
        return $found;
423
    }
424
}
425