• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

wp-graphql / wp-graphql / 13316763745

13 Feb 2025 08:45PM UTC coverage: 82.712% (-0.3%) from 83.023%
13316763745

push

github

web-flow
Merge pull request #3307 from wp-graphql/release/v2.0.0

release: v2.0.0

195 of 270 new or added lines in 20 files covered. (72.22%)

180 existing lines in 42 files now uncovered.

13836 of 16728 relevant lines covered (82.71%)

299.8 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

90.83
/src/Model/Model.php
1
<?php
2

3
namespace WPGraphQL\Model;
4

5
use Exception;
6

7
/**
8
 * Class Model - Abstract class for modeling data for all core types
9
 *
10
 * @package WPGraphQL\Model
11
 */
12
abstract class Model {
13

14
        /**
15
         * Stores the name of the type the child class extending this one represents
16
         *
17
         * @var string
18
         */
19
        protected $model_name;
20

21
        /**
22
         * Stores the raw data passed to the child class when it's instantiated before it's transformed
23
         *
24
         * @var mixed[]|object|mixed
25
         */
26
        protected $data;
27

28
        /**
29
         * Stores the capability name for what to check on the user if the data should be considered
30
         * "Restricted"
31
         *
32
         * @var string
33
         */
34
        protected $restricted_cap;
35

36
        /**
37
         * Stores the array of allowed fields to show if the data is restricted
38
         *
39
         * @var string[]
40
         */
41
        protected $allowed_restricted_fields;
42

43
        /**
44
         * Stores the DB ID of the user that owns this piece of data, or null if there is no owner
45
         *
46
         * @var int|null
47
         */
48
        protected $owner;
49

50
        /**
51
         * Stores the WP_User object for the current user in the session
52
         *
53
         * @var \WP_User $current_user
54
         */
55
        protected $current_user;
56

57
        /**
58
         * Stores the visibility value for the current piece of data
59
         *
60
         * @var string
61
         */
62
        protected $visibility;
63

64
        /**
65
         * The fields for the modeled object. This will be populated in the child class
66
         *
67
         * @var array<string,mixed>
68
         */
69
        public $fields;
70

71
        /**
72
         * Model constructor.
73
         *
74
         * @param string   $restricted_cap            The capability to check against to determine if
75
         *                                            the data should be restricted or not
76
         * @param string[] $allowed_restricted_fields The allowed fields if the data is in fact restricted
77
         * @param int|null $owner                     Database ID of the user that owns this piece of
78
         *                                            data to compare with the current user ID
79
         *
80
         * @return void
81
         * @throws \Exception Throws Exception.
82
         */
83
        protected function __construct( $restricted_cap = '', $allowed_restricted_fields = [], $owner = null ) {
518✔
84
                if ( empty( $this->data ) ) {
518✔
85
                        // translators: %s is the name of the model.
86
                        throw new Exception( esc_html( sprintf( __( 'An empty data set was used to initialize the modeling of this %s object', 'wp-graphql' ), $this->get_model_name() ) ) );
×
87
                }
88

89
                $this->restricted_cap            = $restricted_cap;
518✔
90
                $this->allowed_restricted_fields = $allowed_restricted_fields;
518✔
91
                $this->owner                     = $owner;
518✔
92
                $this->current_user              = wp_get_current_user();
518✔
93

94
                if ( 'private' === $this->get_visibility() ) {
518✔
95
                        return;
38✔
96
                }
97

98
                $this->init();
509✔
99
                $this->prepare_fields();
509✔
100
        }
101

102
        /**
103
         * Magic method to re-map the isset check on the child class looking for properties when
104
         * resolving the fields
105
         *
106
         * @param string $key The name of the field you are trying to retrieve
107
         *
108
         * @return bool
109
         */
110
        public function __isset( $key ) {
506✔
111
                return isset( $this->fields[ $key ] );
506✔
112
        }
113

114
        /**
115
         * Magic method to re-map setting new properties to the class inside of the $fields prop rather
116
         * than on the class in unique properties
117
         *
118
         * @param string                    $key   Name of the key to set the data to
119
         * @param callable|int|string|mixed $value The value to set to the key
120
         *
121
         * @return void
122
         */
123
        public function __set( $key, $value ) {
507✔
124
                $this->fields[ $key ] = $value;
507✔
125
        }
126

127
        /**
128
         * Magic method to re-map where external calls go to look for properties on the child objects.
129
         * This is crucial to let objects modeled through this class work with the default field
130
         * resolver.
131
         *
132
         * @param string $key Name of the property that is trying to be accessed
133
         *
134
         * @return mixed|null
135
         */
136
        public function __get( $key ) {
507✔
137
                if ( isset( $this->fields[ $key ] ) ) {
507✔
138
                        /**
139
                         * If the property has already been processed and cached to the model
140
                         * return the processed value.
141
                         *
142
                         * Otherwise, if it's a callable, process it and cache the value.
143
                         */
144
                        if ( is_scalar( $this->fields[ $key ] ) || ( is_object( $this->fields[ $key ] ) && ! is_callable( $this->fields[ $key ] ) ) || is_array( $this->fields[ $key ] ) ) {
507✔
145
                                return $this->fields[ $key ];
460✔
146
                        } elseif ( is_callable( $this->fields[ $key ] ) ) {
507✔
147
                                $data       = call_user_func( $this->fields[ $key ] );
507✔
148
                                $this->$key = $data;
507✔
149

150
                                return $data;
507✔
151
                        } else {
152
                                return $this->fields[ $key ];
×
153
                        }
154
                } else {
155
                        return null;
3✔
156
                }
157
        }
158

159
        /**
160
         * Setup the global data for the model to have proper context when resolving.
161
         *
162
         * @return void
163
         */
164
        public function setup() {
136✔
165
        }
136✔
166

167
        /**
168
         * Generic model tear down after the fields are setup. This can be used
169
         * to reset state to where it was before the model was setup.
170
         *
171
         * @return void
172
         */
173
        public function tear_down() {
393✔
174
        }
393✔
175

176
        /**
177
         * Returns the name of the model, built from the child className
178
         *
179
         * @return string
180
         */
181
        protected function get_model_name() {
518✔
182
                $name = static::class;
518✔
183

184
                if ( empty( $this->model_name ) ) {
518✔
185
                        if ( false !== strpos( static::class, '\\' ) ) {
518✔
186
                                $starting_character = strrchr( static::class, '\\' );
518✔
187
                                if ( ! empty( $starting_character ) ) {
518✔
188
                                        $name = substr( $starting_character, 1 );
518✔
189
                                }
190
                        }
191
                        $this->model_name = $name . 'Object';
518✔
192
                }
193

194
                return ! empty( $this->model_name ) ? $this->model_name : $name;
518✔
195
        }
196

197
        /**
198
         * Return the visibility state for the current piece of data
199
         *
200
         * @return string|null
201
         */
202
        public function get_visibility() {
518✔
203
                if ( null === $this->visibility ) {
518✔
204

205
                        /**
206
                         * Filter for the capability to check against for restricted data
207
                         *
208
                         * @param string      $restricted_cap The capability to check against
209
                         * @param string      $model_name     Name of the model the filter is currently being executed in
210
                         * @param mixed       $data           The un-modeled incoming data
211
                         * @param string|null $visibility     The visibility that has currently been set for the data at this point
212
                         * @param int|null    $owner          The user ID for the owner of this piece of data
213
                         * @param \WP_User $current_user The current user for the session
214
                         *
215
                         * @return string
216
                         */
217
                        $protected_cap = apply_filters( 'graphql_restricted_data_cap', $this->restricted_cap, $this->get_model_name(), $this->data, $this->visibility, $this->owner, $this->current_user );
518✔
218

219
                        /**
220
                         * Filter to short circuit default is_private check for the model. This is expensive in some cases so
221
                         * this filter lets you prevent this from running by returning a true or false value.
222
                         *
223
                         * @param ?bool       $is_private   Whether the model data is private. Defaults to null.
224
                         * @param string      $model_name   Name of the model the filter is currently being executed in
225
                         * @param mixed       $data         The un-modeled incoming data
226
                         * @param string|null $visibility   The visibility that has currently been set for the data at this point
227
                         * @param int|null    $owner        The user ID for the owner of this piece of data
228
                         * @param \WP_User $current_user The current user for the session
229
                         *
230
                         * @return bool|null
231
                         */
232
                        $pre_is_private = apply_filters( 'graphql_pre_model_data_is_private', null, $this->get_model_name(), $this->data, $this->visibility, $this->owner, $this->current_user );
518✔
233

234
                        // If 3rd party code has not filtered this, use the Models default logic to determine
235
                        // whether the model should be considered private
236
                        if ( null !== $pre_is_private ) {
518✔
237
                                $is_private = $pre_is_private;
×
238
                        } else {
239
                                $is_private = $this->is_private();
518✔
240
                        }
241

242
                        /**
243
                         * Filter to determine if the data should be considered private or not
244
                         *
245
                         * @param bool     $is_private   Whether the model is private
246
                         * @param string      $model_name   Name of the model the filter is currently being executed in
247
                         * @param mixed       $data         The un-modeled incoming data
248
                         * @param string|null $visibility   The visibility that has currently been set for the data at this point
249
                         * @param int|null    $owner        The user ID for the owner of this piece of data
250
                         * @param \WP_User $current_user The current user for the session
251
                         *
252
                         * @return bool
253
                         */
254
                        $is_private = apply_filters( 'graphql_data_is_private', (bool) $is_private, $this->get_model_name(), $this->data, $this->visibility, $this->owner, $this->current_user );
518✔
255

256
                        if ( true === $is_private ) {
518✔
257
                                $this->visibility = 'private';
38✔
258
                        } elseif ( null !== $this->owner && true === $this->owner_matches_current_user() ) {
509✔
259
                                $this->visibility = 'public';
124✔
260
                        } elseif ( empty( $protected_cap ) || current_user_can( $protected_cap ) ) {
450✔
261
                                $this->visibility = 'public';
430✔
262
                        } else {
263
                                $this->visibility = 'restricted';
176✔
264
                        }
265
                }
266

267
                /**
268
                 * Filter the visibility name to be returned
269
                 *
270
                 * @param string|null $visibility   The visibility that has currently been set for the data at this point
271
                 * @param string      $model_name   Name of the model the filter is currently being executed in
272
                 * @param mixed       $data         The un-modeled incoming data
273
                 * @param int|null    $owner        The user ID for the owner of this piece of data
274
                 * @param \WP_User $current_user The current user for the session
275
                 *
276
                 * @return string
277
                 */
278
                return apply_filters( 'graphql_object_visibility', $this->visibility, $this->get_model_name(), $this->data, $this->owner, $this->current_user );
518✔
279
        }
280

281
        /**
282
         * Method to return the private state of the object. Can be overwritten in classes extending
283
         * this one.
284
         *
285
         * @return bool
286
         */
287
        protected function is_private() {
122✔
288
                return false;
122✔
289
        }
290

291
        /**
292
         * Whether or not the owner of the data matches the current user
293
         *
294
         * @return bool
295
         */
296
        protected function owner_matches_current_user() {
401✔
297
                if ( empty( $this->current_user->ID ) || empty( $this->owner ) ) {
401✔
298
                        return false;
265✔
299
                }
300

301
                return absint( $this->owner ) === absint( $this->current_user->ID );
162✔
302
        }
303

304
        /**
305
         * Restricts fields for the data to only return the allowed fields if the data is restricted
306
         *
307
         * @return void
308
         */
309
        protected function restrict_fields() {
176✔
310
                $this->fields = array_intersect_key(
176✔
311
                        $this->fields,
176✔
312
                        array_flip(
176✔
313
                        /**
314
                         * Filter for the allowed restricted fields
315
                         *
316
                         * @param string[]    $allowed_restricted_fields The fields to allow when the data is designated as restricted to the current user
317
                         * @param string      $model_name                Name of the model the filter is currently being executed in
318
                         * @param mixed       $data                      The un-modeled incoming data
319
                         * @param string|null $visibility                The visibility that has currently been set for the data at this point
320
                         * @param int|null    $owner                     The user ID for the owner of this piece of data
321
                         * @param \WP_User $current_user The current user for the session
322
                         */
323
                                apply_filters( 'graphql_allowed_fields_on_restricted_type', $this->allowed_restricted_fields, $this->get_model_name(), $this->data, $this->visibility, $this->owner, $this->current_user )
176✔
324
                        )
176✔
325
                );
176✔
326
        }
327

328
        /**
329
         * Wraps all fields with another callback layer so we can inject hooks & filters into them
330
         *
331
         * @return void
332
         */
333
        protected function wrap_fields() {
509✔
334
                if ( ! is_array( $this->fields ) || empty( $this->fields ) ) {
509✔
335
                        return;
×
336
                }
337

338
                $clean_array = [];
509✔
339
                foreach ( $this->fields as $key => $data ) {
509✔
340
                        $clean_array[ $key ] = function () use ( $key, $data ) {
509✔
341
                                if ( is_array( $data ) ) {
507✔
342
                                        $callback = ( ! empty( $data['callback'] ) ) ? $data['callback'] : null;
17✔
343

344
                                        /**
345
                                         * Capability to check required for the field
346
                                         *
347
                                         * @param string   $capability   The capability to check against to return the field
348
                                         * @param string   $key          The name of the field on the type
349
                                         * @param string   $model_name   Name of the model the filter is currently being executed in
350
                                         * @param mixed    $data         The un-modeled incoming data
351
                                         * @param string   $visibility   The visibility setting for this piece of data
352
                                         * @param int|null $owner        The user ID for the owner of this piece of data
353
                                         * @param \WP_User $current_user The current user for the session
354
                                         *
355
                                         * @return string
356
                                         */
357
                                        $cap_check = ( ! empty( $data['capability'] ) ) ? apply_filters( 'graphql_model_field_capability', $data['capability'], $key, $this->get_model_name(), $this->data, $this->visibility, $this->owner, $this->current_user ) : '';
17✔
358
                                        if ( ! empty( $cap_check ) ) {
17✔
359
                                                if ( ! current_user_can( $data['capability'] ) ) {
17✔
360
                                                        $callback = null;
1✔
361
                                                }
362
                                        }
363
                                } else {
364
                                        $callback = $data;
507✔
365
                                }
366

367
                                /**
368
                                 * Filter to short circuit the callback for any field on a type. Returning anything
369
                                 * other than null will stop the callback for the field from executing, and will
370
                                 * return your data or execute your callback instead.
371
                                 *
372
                                 * @param ?string  $result       The data returned from the callback. Null by default.
373
                                 * @param string   $key          The name of the field on the type
374
                                 * @param string   $model_name   Name of the model the filter is currently being executed in
375
                                 * @param mixed    $data         The un-modeled incoming data
376
                                 * @param string   $visibility   The visibility setting for this piece of data
377
                                 * @param int|null $owner        The user ID for the owner of this piece of data
378
                                 * @param \WP_User $current_user The current user for the session
379
                                 *
380
                                 * @return callable|int|string|mixed[]|mixed|null
381
                                 */
382
                                $pre = apply_filters( 'graphql_pre_return_field_from_model', null, $key, $this->get_model_name(), $this->data, $this->visibility, $this->owner, $this->current_user );
507✔
383

384
                                if ( ! is_null( $pre ) ) {
507✔
385
                                        $result = $pre;
×
386
                                } else {
387
                                        if ( is_callable( $callback ) ) {
507✔
388
                                                $this->setup();
506✔
389
                                                $field = call_user_func( $callback );
506✔
390
                                                $this->tear_down();
506✔
391
                                        } else {
392
                                                $field = $callback;
97✔
393
                                        }
394

395
                                        /**
396
                                         * Filter the data returned by the default callback for the field
397
                                         *
398
                                         * @param string   $field        The data returned from the callback
399
                                         * @param string   $key          The name of the field on the type
400
                                         * @param string   $model_name   Name of the model the filter is currently being executed in
401
                                         * @param mixed    $data         The un-modeled incoming data
402
                                         * @param string   $visibility   The visibility setting for this piece of data
403
                                         * @param int|null $owner        The user ID for the owner of this piece of data
404
                                         * @param \WP_User $current_user The current user for the session
405
                                         *
406
                                         * @return mixed
407
                                         */
408
                                        $result = apply_filters( 'graphql_return_field_from_model', $field, $key, $this->get_model_name(), $this->data, $this->visibility, $this->owner, $this->current_user );
507✔
409
                                }
410

411
                                /**
412
                                 * Hook that fires after the data is returned for the field
413
                                 *
414
                                 * @param string   $result       The returned data for the field
415
                                 * @param string   $key          The name of the field on the type
416
                                 * @param string   $model_name   Name of the model the filter is currently being executed in
417
                                 * @param mixed    $data         The un-modeled incoming data
418
                                 * @param string   $visibility   The visibility setting for this piece of data
419
                                 * @param int|null $owner        The user ID for the owner of this piece of data
420
                                 * @param \WP_User $current_user The current user for the session
421
                                 */
422
                                do_action( 'graphql_after_return_field_from_model', $result, $key, $this->get_model_name(), $this->data, $this->visibility, $this->owner, $this->current_user );
507✔
423

424
                                return $result;
507✔
425
                        };
509✔
426
                }
427

428
                $this->fields = $clean_array;
509✔
429
        }
430

431
        /**
432
         * Adds the model visibility fields to the data
433
         *
434
         * @return void
435
         */
436
        private function add_model_visibility() {
509✔
437

438
                /**
439
                 * @todo: potentially abstract this out into a more central spot
440
                 */
441
                $this->fields['isPublic']     = function () {
509✔
442
                        return 'public' === $this->get_visibility();
×
443
                };
509✔
444
                $this->fields['isRestricted'] = function () {
509✔
445
                        return 'restricted' === $this->get_visibility();
1✔
446
                };
509✔
447
                $this->fields['isPrivate']    = function () {
509✔
448
                        return 'private' === $this->get_visibility();
6✔
449
                };
509✔
450
        }
451

452
        /**
453
         * Returns instance of the data fully modeled
454
         *
455
         * @return void
456
         */
457
        protected function prepare_fields() {
509✔
458
                if ( 'restricted' === $this->get_visibility() ) {
509✔
459
                        $this->restrict_fields();
176✔
460
                }
461

462
                /**
463
                 * Add support for the deprecated "graphql_return_modeled_data" filter.
464
                 *
465
                 * @param array<string,mixed>    $fields       The array of fields for the model
466
                 * @param string                 $model_name   Name of the model the filter is currently being executed in
467
                 * @param string                 $visibility   The visibility setting for this piece of data
468
                 * @param ?int                   $owner        The user ID for the owner of this piece of data
469
                 * @param \WP_User               $current_user The current user for the session
470
                 *
471
                 * @deprecated 1.7.0 use "graphql_model_prepare_fields" filter instead, which passes additional context to the filter
472
                 */
473
                $this->fields = apply_filters_deprecated( 'graphql_return_modeled_data', [ $this->fields, $this->get_model_name(), $this->visibility, $this->owner, $this->current_user ], '1.7.0', 'graphql_model_prepare_fields' );
509✔
474

475
                /**
476
                 * Filter the array of fields for the Model before the object is hydrated with it
477
                 *
478
                 * @param array<string,mixed>    $fields       The array of fields for the model
479
                 * @param string                 $model_name   Name of the model the filter is currently being executed in
480
                 * @param mixed                  $data         The un-modeled incoming data
481
                 * @param string                 $visibility   The visibility setting for this piece of data
482
                 * @param ?int                   $owner        The user ID for the owner of this piece of data
483
                 * @param \WP_User               $current_user The current user for the session
484
                 */
485
                $this->fields = apply_filters( 'graphql_model_prepare_fields', $this->fields, $this->get_model_name(), $this->data, $this->visibility, $this->owner, $this->current_user );
509✔
486
                $this->wrap_fields();
509✔
487
                $this->add_model_visibility();
509✔
488
        }
489

490
        /**
491
         * Given a string, and optional context, this decodes html entities if html_entity_decode is
492
         * enabled.
493
         *
494
         * @param string $str        The string to decode
495
         * @param string $field_name The name of the field being encoded
496
         * @param bool   $enabled    Whether decoding is enabled by default for the string passed in
497
         *
498
         * @return string
499
         */
500
        public function html_entity_decode( $str, $field_name, $enabled = false ) {
212✔
501

502
                /**
503
                 * Determine whether html_entity_decode should be applied to the string
504
                 *
505
                 * @param bool                   $enabled    Whether decoding is enabled by default for the string passed in
506
                 * @param string                 $str        The string to decode
507
                 * @param string                 $field_name The name of the field being encoded
508
                 * @param \WPGraphQL\Model\Model $model      The Model the field is being decoded on
509
                 */
510
                $decoding_enabled = apply_filters( 'graphql_html_entity_decoding_enabled', $enabled, $str, $field_name, $this );
212✔
511

512
                if ( false === $decoding_enabled ) {
212✔
513
                        return $str;
96✔
514
                }
515

516
                return html_entity_decode( $str, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401, 'UTF-8' );
175✔
517
        }
518

519
        /**
520
         * Filter the fields returned for the object
521
         *
522
         * @param string|mixed[]|null $fields The field or fields to build in the modeled object. You can
523
         *                                  pass null to build all of the fields, a string to only
524
         *                                  build an object with one field, or an array of field keys
525
         *                                  to build an object with those keys and their respective values.
526
         *
527
         * @return void
528
         */
UNCOV
529
        public function filter( $fields ) {
×
530
                if ( is_string( $fields ) ) {
×
531
                        $fields = [ $fields ];
×
532
                }
533

534
                if ( is_array( $fields ) ) {
×
535
                        $this->fields = array_intersect_key( $this->fields, array_flip( $fields ) );
×
536
                }
537
        }
538

539
        /**
540
         * Initialized the object.
541
         *
542
         * @return void
543
         */
544
        abstract protected function init();
545
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc