Passed
Push — sheepy/introspection ( 000d40...b6b869 )
by Marco
02:43
created

SearchIntrospection::getHierarchy()   B

Complexity

Conditions 7
Paths 3

Size

Total Lines 27
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 14
CRAP Score 7

Importance

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