Passed
Push — 4.0 ( 01b48e...d290ee )
by Damian
07:58
created

ViewableData::getViewerTemplates()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 1
nc 1
nop 1
dl 0
loc 3
rs 10
c 0
b 0
f 0
1
<?php
2
3
namespace SilverStripe\View;
4
5
use ArrayIterator;
6
use Exception;
7
use InvalidArgumentException;
8
use IteratorAggregate;
9
use LogicException;
10
use SilverStripe\Core\ClassInfo;
11
use SilverStripe\Core\Config\Config;
12
use SilverStripe\Core\Config\Configurable;
13
use SilverStripe\Core\Convert;
14
use SilverStripe\Core\Extensible;
15
use SilverStripe\Core\Injector\Injectable;
16
use SilverStripe\Core\Injector\Injector;
17
use SilverStripe\Core\Manifest\ModuleResourceLoader;
18
use SilverStripe\Dev\Debug;
19
use SilverStripe\Dev\Deprecation;
20
use SilverStripe\ORM\ArrayLib;
21
use SilverStripe\ORM\FieldType\DBField;
22
use SilverStripe\ORM\FieldType\DBHTMLText;
23
use SilverStripe\View\SSViewer;
24
use UnexpectedValueException;
25
26
/**
27
 * A ViewableData object is any object that can be rendered into a template/view.
28
 *
29
 * A view interrogates the object being currently rendered in order to get data to render into the template. This data
30
 * is provided and automatically escaped by ViewableData. Any class that needs to be available to a view (controllers,
31
 * {@link DataObject}s, page controls) should inherit from this class.
32
 */
33
class ViewableData implements IteratorAggregate
34
{
35
    use Extensible {
36
        defineMethods as extensibleDefineMethods;
37
    }
38
    use Injectable;
39
    use Configurable;
40
41
    /**
42
     * An array of objects to cast certain fields to. This is set up as an array in the format:
43
     *
44
     * <code>
45
     * public static $casting = array (
46
     *     'FieldName' => 'ClassToCastTo(Arguments)'
47
     * );
48
     * </code>
49
     *
50
     * @var array
51
     * @config
52
     */
53
    private static $casting = array(
54
        'CSSClasses' => 'Varchar'
55
    );
56
57
    /**
58
     * The default object to cast scalar fields to if casting information is not specified, and casting to an object
59
     * is required.
60
     *
61
     * @var string
62
     * @config
63
     */
64
    private static $default_cast = 'Text';
65
66
    /**
67
     * @var array
68
     */
69
    private static $casting_cache = array();
70
71
    // -----------------------------------------------------------------------------------------------------------------
72
73
    /**
74
     * A failover object to attempt to get data from if it is not present on this object.
75
     *
76
     * @var ViewableData
77
     */
78
    protected $failover;
79
80
    /**
81
     * @var ViewableData
82
     */
83
    protected $customisedObject;
84
85
    /**
86
     * @var array
87
     */
88
    private $objCache = array();
89
90
    public function __construct()
91
    {
92
    }
93
94
    // -----------------------------------------------------------------------------------------------------------------
95
96
    // FIELD GETTERS & SETTERS -----------------------------------------------------------------------------------------
97
98
    /**
99
     * Check if a field exists on this object or its failover.
100
     * Note that, unlike the core isset() implementation, this will return true if the property is defined
101
     * and set to null.
102
     *
103
     * @param string $property
104
     * @return bool
105
     */
106
    public function __isset($property)
107
    {
108
        // getField() isn't a field-specific getter and shouldn't be treated as such
109
        if (strtolower($property) !== 'field' && $this->hasMethod($method = "get$property")) {
110
            return true;
111
        } elseif ($this->hasField($property)) {
112
            return true;
113
        } elseif ($this->failover) {
114
            return isset($this->failover->$property);
115
        }
116
117
        return false;
118
    }
119
120
    /**
121
     * Get the value of a property/field on this object. This will check if a method called get{$property} exists, then
122
     * check if a field is available using {@link ViewableData::getField()}, then fall back on a failover object.
123
     *
124
     * @param string $property
125
     * @return mixed
126
     */
127
    public function __get($property)
128
    {
129
        // getField() isn't a field-specific getter and shouldn't be treated as such
130
        if (strtolower($property) !== 'field' && $this->hasMethod($method = "get$property")) {
131
            return $this->$method();
132
        } elseif ($this->hasField($property)) {
133
            return $this->getField($property);
134
        } elseif ($this->failover) {
135
            return $this->failover->$property;
136
        }
137
138
        return null;
139
    }
140
141
    /**
142
     * Set a property/field on this object. This will check for the existence of a method called set{$property}, then
143
     * use the {@link ViewableData::setField()} method.
144
     *
145
     * @param string $property
146
     * @param mixed $value
147
     */
148
    public function __set($property, $value)
149
    {
150
        $this->objCacheClear();
151
        if ($this->hasMethod($method = "set$property")) {
152
            $this->$method($value);
153
        } else {
154
            $this->setField($property, $value);
155
        }
156
    }
157
158
    /**
159
     * Set a failover object to attempt to get data from if it is not present on this object.
160
     *
161
     * @param ViewableData $failover
162
     */
163
    public function setFailover(ViewableData $failover)
164
    {
165
        // Ensure cached methods from previous failover are removed
166
        if ($this->failover) {
167
            $this->removeMethodsFrom('failover');
168
        }
169
170
        $this->failover = $failover;
171
        $this->defineMethods();
172
    }
173
174
    /**
175
     * Get the current failover object if set
176
     *
177
     * @return ViewableData|null
178
     */
179
    public function getFailover()
180
    {
181
        return $this->failover;
182
    }
183
184
    /**
185
     * Check if a field exists on this object. This should be overloaded in child classes.
186
     *
187
     * @param string $field
188
     * @return bool
189
     */
190
    public function hasField($field)
191
    {
192
        return property_exists($this, $field);
193
    }
194
195
    /**
196
     * Get the value of a field on this object. This should be overloaded in child classes.
197
     *
198
     * @param string $field
199
     * @return mixed
200
     */
201
    public function getField($field)
202
    {
203
        return $this->$field;
204
    }
205
206
    /**
207
     * Set a field on this object. This should be overloaded in child classes.
208
     *
209
     * @param string $field
210
     * @param mixed $value
211
     * @return $this
212
     */
213
    public function setField($field, $value)
214
    {
215
        $this->objCacheClear();
216
        $this->$field = $value;
217
        return $this;
218
    }
219
220
    // -----------------------------------------------------------------------------------------------------------------
221
222
    /**
223
     * Add methods from the {@link ViewableData::$failover} object, as well as wrapping any methods prefixed with an
224
     * underscore into a {@link ViewableData::cachedCall()}.
225
     *
226
     * @throws LogicException
227
     */
228
    public function defineMethods()
229
    {
230
        if ($this->failover && !is_object($this->failover)) {
231
            throw new LogicException("ViewableData::\$failover set to a non-object");
232
        }
233
        if ($this->failover) {
234
            $this->addMethodsFrom('failover');
235
236
            if (isset($_REQUEST['debugfailover'])) {
237
                $class = static::class;
238
                $failoverClass = get_class($this->failover);
239
                Debug::message("$class created with a failover class of {$failoverClass}");
240
            }
241
        }
242
        $this->extensibleDefineMethods();
243
    }
244
245
    /**
246
     * Merge some arbitrary data in with this object. This method returns a {@link ViewableData_Customised} instance
247
     * with references to both this and the new custom data.
248
     *
249
     * Note that any fields you specify will take precedence over the fields on this object.
250
     *
251
     * @param array|ViewableData $data
252
     * @return ViewableData_Customised
253
     */
254
    public function customise($data)
255
    {
256
        if (is_array($data) && (empty($data) || ArrayLib::is_associative($data))) {
257
            $data = new ArrayData($data);
258
        }
259
260
        if ($data instanceof ViewableData) {
261
            return new ViewableData_Customised($this, $data);
262
        }
263
264
        throw new InvalidArgumentException(
265
            'ViewableData->customise(): $data must be an associative array or a ViewableData instance'
266
        );
267
    }
268
269
    /**
270
     * Return true if this object "exists" i.e. has a sensible value
271
     *
272
     * This method should be overriden in subclasses to provide more context about the classes state. For example, a
273
     * {@link DataObject} class could return false when it is deleted from the database
274
     *
275
     * @return bool
276
     */
277
    public function exists()
278
    {
279
        return true;
280
    }
281
282
    /**
283
     * @return string the class name
284
     */
285
    public function __toString()
286
    {
287
        return static::class;
288
    }
289
290
    /**
291
     * @return ViewableData
292
     */
293
    public function getCustomisedObj()
294
    {
295
        return $this->customisedObject;
296
    }
297
298
    /**
299
     * @param ViewableData $object
300
     */
301
    public function setCustomisedObj(ViewableData $object)
302
    {
303
        $this->customisedObject = $object;
304
    }
305
306
    // CASTING ---------------------------------------------------------------------------------------------------------
307
308
    /**
309
     * Return the "casting helper" (a piece of PHP code that when evaluated creates a casted value object)
310
     * for a field on this object. This helper will be a subclass of DBField.
311
     *
312
     * @param string $field
313
     * @return string Casting helper As a constructor pattern, and may include arguments.
314
     * @throws Exception
315
     */
316
    public function castingHelper($field)
317
    {
318
        $specs = static::config()->get('casting');
319
        if (isset($specs[$field])) {
320
            return $specs[$field];
321
        }
322
323
        // If no specific cast is declared, fall back to failover.
324
        // Note that if there is a failover, the default_cast will always
325
        // be drawn from this object instead of the top level object.
326
        $failover = $this->getFailover();
327
        if ($failover) {
328
            $cast = $failover->castingHelper($field);
329
            if ($cast) {
330
                return $cast;
331
            }
332
        }
333
334
        // Fall back to default_cast
335
        $default = $this->config()->get('default_cast');
336
        if (empty($default)) {
337
            throw new Exception("No default_cast");
338
        }
339
        return $default;
340
    }
341
342
    /**
343
     * Get the class name a field on this object will be casted to.
344
     *
345
     * @param string $field
346
     * @return string
347
     */
348
    public function castingClass($field)
349
    {
350
        // Strip arguments
351
        $spec = $this->castingHelper($field);
352
        return trim(strtok($spec, '('));
353
    }
354
355
    /**
356
     * Return the string-format type for the given field.
357
     *
358
     * @param string $field
359
     * @return string 'xml'|'raw'
360
     */
361
    public function escapeTypeForField($field)
362
    {
363
        $class = $this->castingClass($field) ?: $this->config()->get('default_cast');
364
365
        // TODO: It would be quicker not to instantiate the object, but to merely
366
        // get its class from the Injector
367
        /** @var DBField $type */
368
        $type = Injector::inst()->get($class, true);
369
        return $type->config()->get('escape_type');
370
    }
371
372
    // TEMPLATE ACCESS LAYER -------------------------------------------------------------------------------------------
373
374
    /**
375
     * Render this object into the template, and get the result as a string. You can pass one of the following as the
376
     * $template parameter:
377
     *  - a template name (e.g. Page)
378
     *  - an array of possible template names - the first valid one will be used
379
     *  - an SSViewer instance
380
     *
381
     * @param string|array|SSViewer $template the template to render into
382
     * @param array $customFields fields to customise() the object with before rendering
383
     * @return DBHTMLText
384
     */
385
    public function renderWith($template, $customFields = null)
386
    {
387
        if (!is_object($template)) {
388
            $template = SSViewer::create($template);
0 ignored issues
show
Bug introduced by
It seems like $template can also be of type string; however, parameter $args of SilverStripe\View\SSViewer::create() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

388
            $template = SSViewer::create(/** @scrutinizer ignore-type */ $template);
Loading history...
389
        }
390
391
        $data = $this->getCustomisedObj() ?: $this;
392
393
        if ($customFields instanceof ViewableData) {
394
            $data = $data->customise($customFields);
395
        }
396
        if ($template instanceof SSViewer) {
397
            return $template->process($data, is_array($customFields) ? $customFields : null);
398
        }
399
400
        throw new UnexpectedValueException(
401
            "ViewableData::renderWith(): unexpected ".get_class($template)." object, expected an SSViewer instance"
402
        );
403
    }
404
405
    /**
406
     * Generate the cache name for a field
407
     *
408
     * @param string $fieldName Name of field
409
     * @param array $arguments List of optional arguments given
410
     * @return string
411
     */
412
    protected function objCacheName($fieldName, $arguments)
413
    {
414
        return $arguments
415
            ? $fieldName . ":" . implode(',', $arguments)
416
            : $fieldName;
417
    }
418
419
    /**
420
     * Get a cached value from the field cache
421
     *
422
     * @param string $key Cache key
423
     * @return mixed
424
     */
425
    protected function objCacheGet($key)
426
    {
427
        if (isset($this->objCache[$key])) {
428
            return $this->objCache[$key];
429
        }
430
        return null;
431
    }
432
433
    /**
434
     * Store a value in the field cache
435
     *
436
     * @param string $key Cache key
437
     * @param mixed $value
438
     * @return $this
439
     */
440
    protected function objCacheSet($key, $value)
441
    {
442
        $this->objCache[$key] = $value;
443
        return $this;
444
    }
445
446
    /**
447
     * Clear object cache
448
     *
449
     * @return $this
450
     */
451
    protected function objCacheClear()
452
    {
453
        $this->objCache = [];
454
        return $this;
455
    }
456
457
    /**
458
     * Get the value of a field on this object, automatically inserting the value into any available casting objects
459
     * that have been specified.
460
     *
461
     * @param string $fieldName
462
     * @param array $arguments
463
     * @param bool $cache Cache this object
464
     * @param string $cacheName a custom cache name
465
     * @return Object|DBField
466
     */
467
    public function obj($fieldName, $arguments = [], $cache = false, $cacheName = null)
468
    {
469
        if (!$cacheName && $cache) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $cacheName of type null|string is loosely compared to false; this is ambiguous if the string can be empty. You might want to explicitly use === null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
470
            $cacheName = $this->objCacheName($fieldName, $arguments);
471
        }
472
473
        // Check pre-cached value
474
        $value = $cache ? $this->objCacheGet($cacheName) : null;
475
        if ($value !== null) {
476
            return $value;
477
        }
478
479
        // Load value from record
480
        if ($this->hasMethod($fieldName)) {
481
            $value = call_user_func_array(array($this, $fieldName), $arguments ?: []);
482
        } else {
483
            $value = $this->$fieldName;
484
        }
485
486
        // Cast object
487
        if (!is_object($value)) {
488
            // Force cast
489
            $castingHelper = $this->castingHelper($fieldName);
490
            $valueObject = Injector::inst()->create($castingHelper, $fieldName);
491
            $valueObject->setValue($value, $this);
492
            $value = $valueObject;
493
        }
494
495
        // Record in cache
496
        if ($cache) {
497
            $this->objCacheSet($cacheName, $value);
498
        }
499
500
        return $value;
501
    }
502
503
    /**
504
     * A simple wrapper around {@link ViewableData::obj()} that automatically caches the result so it can be used again
505
     * without re-running the method.
506
     *
507
     * @param string $field
508
     * @param array $arguments
509
     * @param string $identifier an optional custom cache identifier
510
     * @return Object|DBField
511
     */
512
    public function cachedCall($field, $arguments = [], $identifier = null)
513
    {
514
        return $this->obj($field, $arguments, true, $identifier);
515
    }
516
517
    /**
518
     * Checks if a given method/field has a valid value. If the result is an object, this will return the result of the
519
     * exists method, otherwise will check if the result is not just an empty paragraph tag.
520
     *
521
     * @param string $field
522
     * @param array $arguments
523
     * @param bool $cache
524
     * @return bool
525
     */
526
    public function hasValue($field, $arguments = [], $cache = true)
527
    {
528
        $result = $this->obj($field, $arguments, $cache);
529
            return $result->exists();
530
    }
531
532
    /**
533
     * Get the string value of a field on this object that has been suitable escaped to be inserted directly into a
534
     * template.
535
     *
536
     * @param string $field
537
     * @param array $arguments
538
     * @param bool $cache
539
     * @return string
540
     */
541
    public function XML_val($field, $arguments = [], $cache = false)
542
    {
543
        $result = $this->obj($field, $arguments, $cache);
544
        // Might contain additional formatting over ->XML(). E.g. parse shortcodes, nl2br()
545
        return $result->forTemplate();
546
    }
547
548
    /**
549
     * Get an array of XML-escaped values by field name
550
     *
551
     * @param array $fields an array of field names
552
     * @return array
553
     */
554
    public function getXMLValues($fields)
555
    {
556
        $result = array();
557
558
        foreach ($fields as $field) {
559
            $result[$field] = $this->XML_val($field);
560
        }
561
562
        return $result;
563
    }
564
565
    // ITERATOR SUPPORT ------------------------------------------------------------------------------------------------
566
567
    /**
568
     * Return a single-item iterator so you can iterate over the fields of a single record.
569
     *
570
     * This is useful so you can use a single record inside a <% control %> block in a template - and then use
571
     * to access individual fields on this object.
572
     *
573
     * @return ArrayIterator
574
     */
575
    public function getIterator()
576
    {
577
        return new ArrayIterator(array($this));
578
    }
579
580
    // UTILITY METHODS -------------------------------------------------------------------------------------------------
581
582
    /**
583
     * Find appropriate templates for SSViewer to use to render this object
584
     *
585
     * @param string $suffix
586
     * @return array
587
     */
588
    public function getViewerTemplates($suffix = '')
589
    {
590
        return SSViewer::get_templates_by_class(static::class, $suffix, self::class);
591
    }
592
593
    /**
594
     * When rendering some objects it is necessary to iterate over the object being rendered, to do this, you need
595
     * access to itself.
596
     *
597
     * @return ViewableData
598
     */
599
    public function Me()
600
    {
601
        return $this;
602
    }
603
604
    /**
605
     * Return the directory if the current active theme (relative to the site root).
606
     *
607
     * This method is useful for things such as accessing theme images from your template without hardcoding the theme
608
     * page - e.g. <img src="$ThemeDir/images/something.gif">.
609
     *
610
     * This method should only be used when a theme is currently active. However, it will fall over to the current
611
     * project directory.
612
     *
613
     * @return string URL to the current theme
614
     * @deprecated 4.0.0..5.0.0 Use $resourcePath or $resourceURL template helpers instead
615
     */
616
    public function ThemeDir()
617
    {
618
        Deprecation::notice('5.0', 'Use $resourcePath or $resourceURL template helpers instead');
619
        $themes = SSViewer::get_themes();
620
        foreach ($themes as $theme) {
621
            // Skip theme sets
622
            if (strpos($theme, '$') === 0) {
623
                continue;
624
            }
625
            // Map theme path to url
626
            $themePath = ThemeResourceLoader::inst()->getPath($theme);
627
            return ModuleResourceLoader::resourceURL($themePath);
628
        }
629
630
        return project();
631
    }
632
633
    /**
634
     * Get part of the current classes ancestry to be used as a CSS class.
635
     *
636
     * This method returns an escaped string of CSS classes representing the current classes ancestry until it hits a
637
     * stop point - e.g. "Page DataObject ViewableData".
638
     *
639
     * @param string $stopAtClass the class to stop at (default: ViewableData)
640
     * @return string
641
     * @uses ClassInfo
642
     */
643
    public function CSSClasses($stopAtClass = self::class)
644
    {
645
        $classes       = array();
646
        $classAncestry = array_reverse(ClassInfo::ancestry(static::class));
647
        $stopClasses   = ClassInfo::ancestry($stopAtClass);
648
649
        foreach ($classAncestry as $class) {
650
            if (in_array($class, $stopClasses)) {
651
                break;
652
            }
653
            $classes[] = $class;
654
        }
655
656
        // optionally add template identifier
657
        if (isset($this->template) && !in_array($this->template, $classes)) {
0 ignored issues
show
Bug Best Practice introduced by
The property template does not exist on SilverStripe\View\ViewableData. Since you implemented __get, consider adding a @property annotation.
Loading history...
658
            $classes[] = $this->template;
659
        }
660
661
        // Strip out namespaces
662
        $classes = preg_replace('#.*\\\\#', '', $classes);
663
664
        return Convert::raw2att(implode(' ', $classes));
665
    }
666
667
    /**
668
     * Return debug information about this object that can be rendered into a template
669
     *
670
     * @return ViewableData_Debugger
671
     */
672
    public function Debug()
673
    {
674
        return new ViewableData_Debugger($this);
675
    }
676
}
677