Passed
Pull Request — master (#187)
by Simon
09:18 queued 03:24
created

FieldResolver::findOrigin()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 17
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 9
CRAP Score 3

Importance

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