Completed
Push — master ( 32a670...32a37c )
by Damian
37s queued 21s
created

src/View/ViewableData.php (1 issue)

Labels
Severity
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
        }
112
        if ($this->hasField($property)) {
113
            return true;
114
        }
115
        if ($this->failover) {
116
            return isset($this->failover->$property);
117
        }
118
119
        return false;
120
    }
121
122
    /**
123
     * Get the value of a property/field on this object. This will check if a method called get{$property} exists, then
124
     * check if a field is available using {@link ViewableData::getField()}, then fall back on a failover object.
125
     *
126
     * @param string $property
127
     * @return mixed
128
     */
129
    public function __get($property)
130
    {
131
        // getField() isn't a field-specific getter and shouldn't be treated as such
132
        if (strtolower($property) !== 'field' && $this->hasMethod($method = "get$property")) {
133
            return $this->$method();
134
        }
135
        if ($this->hasField($property)) {
136
            return $this->getField($property);
137
        }
138
        if ($this->failover) {
139
            return $this->failover->$property;
140
        }
141
142
        return null;
143
    }
144
145
    /**
146
     * Set a property/field on this object. This will check for the existence of a method called set{$property}, then
147
     * use the {@link ViewableData::setField()} method.
148
     *
149
     * @param string $property
150
     * @param mixed $value
151
     */
152
    public function __set($property, $value)
153
    {
154
        $this->objCacheClear();
155
        if ($this->hasMethod($method = "set$property")) {
156
            $this->$method($value);
157
        } else {
158
            $this->setField($property, $value);
159
        }
160
    }
161
162
    /**
163
     * Set a failover object to attempt to get data from if it is not present on this object.
164
     *
165
     * @param ViewableData $failover
166
     */
167
    public function setFailover(ViewableData $failover)
168
    {
169
        // Ensure cached methods from previous failover are removed
170
        if ($this->failover) {
171
            $this->removeMethodsFrom('failover');
172
        }
173
174
        $this->failover = $failover;
175
        $this->defineMethods();
176
    }
177
178
    /**
179
     * Get the current failover object if set
180
     *
181
     * @return ViewableData|null
182
     */
183
    public function getFailover()
184
    {
185
        return $this->failover;
186
    }
187
188
    /**
189
     * Check if a field exists on this object. This should be overloaded in child classes.
190
     *
191
     * @param string $field
192
     * @return bool
193
     */
194
    public function hasField($field)
195
    {
196
        return property_exists($this, $field);
197
    }
198
199
    /**
200
     * Get the value of a field on this object. This should be overloaded in child classes.
201
     *
202
     * @param string $field
203
     * @return mixed
204
     */
205
    public function getField($field)
206
    {
207
        return $this->$field;
208
    }
209
210
    /**
211
     * Set a field on this object. This should be overloaded in child classes.
212
     *
213
     * @param string $field
214
     * @param mixed $value
215
     * @return $this
216
     */
217
    public function setField($field, $value)
218
    {
219
        $this->objCacheClear();
220
        $this->$field = $value;
221
        return $this;
222
    }
223
224
    // -----------------------------------------------------------------------------------------------------------------
225
226
    /**
227
     * Add methods from the {@link ViewableData::$failover} object, as well as wrapping any methods prefixed with an
228
     * underscore into a {@link ViewableData::cachedCall()}.
229
     *
230
     * @throws LogicException
231
     */
232
    public function defineMethods()
233
    {
234
        if ($this->failover && !is_object($this->failover)) {
235
            throw new LogicException("ViewableData::\$failover set to a non-object");
236
        }
237
        if ($this->failover) {
238
            $this->addMethodsFrom('failover');
239
240
            if (isset($_REQUEST['debugfailover'])) {
241
                $class = static::class;
242
                $failoverClass = get_class($this->failover);
243
                Debug::message("$class created with a failover class of {$failoverClass}");
244
            }
245
        }
246
        $this->extensibleDefineMethods();
247
    }
248
249
    /**
250
     * Merge some arbitrary data in with this object. This method returns a {@link ViewableData_Customised} instance
251
     * with references to both this and the new custom data.
252
     *
253
     * Note that any fields you specify will take precedence over the fields on this object.
254
     *
255
     * @param array|ViewableData $data
256
     * @return ViewableData_Customised
257
     */
258
    public function customise($data)
259
    {
260
        if (is_array($data) && (empty($data) || ArrayLib::is_associative($data))) {
261
            $data = new ArrayData($data);
262
        }
263
264
        if ($data instanceof ViewableData) {
265
            return new ViewableData_Customised($this, $data);
266
        }
267
268
        throw new InvalidArgumentException(
269
            'ViewableData->customise(): $data must be an associative array or a ViewableData instance'
270
        );
271
    }
272
273
    /**
274
     * Return true if this object "exists" i.e. has a sensible value
275
     *
276
     * This method should be overriden in subclasses to provide more context about the classes state. For example, a
277
     * {@link DataObject} class could return false when it is deleted from the database
278
     *
279
     * @return bool
280
     */
281
    public function exists()
282
    {
283
        return true;
284
    }
285
286
    /**
287
     * @return string the class name
288
     */
289
    public function __toString()
290
    {
291
        return static::class;
292
    }
293
294
    /**
295
     * @return ViewableData
296
     */
297
    public function getCustomisedObj()
298
    {
299
        return $this->customisedObject;
300
    }
301
302
    /**
303
     * @param ViewableData $object
304
     */
305
    public function setCustomisedObj(ViewableData $object)
306
    {
307
        $this->customisedObject = $object;
308
    }
309
310
    // CASTING ---------------------------------------------------------------------------------------------------------
311
312
    /**
313
     * Return the "casting helper" (a piece of PHP code that when evaluated creates a casted value object)
314
     * for a field on this object. This helper will be a subclass of DBField.
315
     *
316
     * @param string $field
317
     * @return string Casting helper As a constructor pattern, and may include arguments.
318
     * @throws Exception
319
     */
320
    public function castingHelper($field)
321
    {
322
        $specs = static::config()->get('casting');
323
        if (isset($specs[$field])) {
324
            return $specs[$field];
325
        }
326
327
        // If no specific cast is declared, fall back to failover.
328
        // Note that if there is a failover, the default_cast will always
329
        // be drawn from this object instead of the top level object.
330
        $failover = $this->getFailover();
331
        if ($failover) {
332
            $cast = $failover->castingHelper($field);
333
            if ($cast) {
334
                return $cast;
335
            }
336
        }
337
338
        // Fall back to default_cast
339
        $default = $this->config()->get('default_cast');
340
        if (empty($default)) {
341
            throw new Exception("No default_cast");
342
        }
343
        return $default;
344
    }
345
346
    /**
347
     * Get the class name a field on this object will be casted to.
348
     *
349
     * @param string $field
350
     * @return string
351
     */
352
    public function castingClass($field)
353
    {
354
        // Strip arguments
355
        $spec = $this->castingHelper($field);
356
        return trim(strtok($spec, '('));
357
    }
358
359
    /**
360
     * Return the string-format type for the given field.
361
     *
362
     * @param string $field
363
     * @return string 'xml'|'raw'
364
     */
365
    public function escapeTypeForField($field)
366
    {
367
        $class = $this->castingClass($field) ?: $this->config()->get('default_cast');
368
369
        // TODO: It would be quicker not to instantiate the object, but to merely
370
        // get its class from the Injector
371
        /** @var DBField $type */
372
        $type = Injector::inst()->get($class, true);
373
        return $type->config()->get('escape_type');
374
    }
375
376
    // TEMPLATE ACCESS LAYER -------------------------------------------------------------------------------------------
377
378
    /**
379
     * Render this object into the template, and get the result as a string. You can pass one of the following as the
380
     * $template parameter:
381
     *  - a template name (e.g. Page)
382
     *  - an array of possible template names - the first valid one will be used
383
     *  - an SSViewer instance
384
     *
385
     * @param string|array|SSViewer $template the template to render into
386
     * @param array $customFields fields to customise() the object with before rendering
387
     * @return DBHTMLText
388
     */
389
    public function renderWith($template, $customFields = null)
390
    {
391
        if (!is_object($template)) {
392
            $template = SSViewer::create($template);
393
        }
394
395
        $data = $this->getCustomisedObj() ?: $this;
396
397
        if ($customFields instanceof ViewableData) {
398
            $data = $data->customise($customFields);
399
        }
400
        if ($template instanceof SSViewer) {
401
            return $template->process($data, is_array($customFields) ? $customFields : null);
402
        }
403
404
        throw new UnexpectedValueException(
405
            "ViewableData::renderWith(): unexpected " . get_class($template) . " object, expected an SSViewer instance"
0 ignored issues
show
$template of type array|string is incompatible with the type object expected by parameter $object of get_class(). ( Ignorable by Annotation )

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

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