Passed
Push — sheepy/introspection ( 9a33c8...7d1300 )
by Marco
05:54
created

SearchIntrospection::getFieldIntrospection()   B

Complexity

Conditions 7
Paths 9

Size

Total Lines 39
Code Lines 20

Duplication

Lines 0
Ratio 0 %

Importance

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