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

wp-graphql / wp-graphql / 15710056976

17 Jun 2025 02:27PM UTC coverage: 84.17% (-0.1%) from 84.287%
15710056976

push

github

actions-user
release: merge develop into master for v2.3.3

15925 of 18920 relevant lines covered (84.17%)

258.66 hits per line

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

92.0
/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
 * @property bool $isPrivate
11
 * @property bool $isPublic
12
 * @property bool $isRestricted
13
 *
14
 * @template TData
15
 */
16
abstract class Model {
17

18
        /**
19
         * Stores the name of the type the child class extending this one represents
20
         *
21
         * @var string
22
         */
23
        protected $model_name;
24

25
        /**
26
         * Stores the raw data passed to the child class when it's instantiated before it's transformed
27
         *
28
         * @var TData
29
         */
30
        protected $data;
31

32
        /**
33
         * Stores the capability name for what to check on the user if the data should be considered
34
         * "Restricted"
35
         *
36
         * @var string
37
         */
38
        protected $restricted_cap;
39

40
        /**
41
         * Stores the array of allowed fields to show if the data is restricted
42
         *
43
         * @var string[]
44
         */
45
        protected $allowed_restricted_fields;
46

47
        /**
48
         * Stores the DB ID of the user that owns this piece of data, or null if there is no owner
49
         *
50
         * @var int|null
51
         */
52
        protected $owner;
53

54
        /**
55
         * Stores the WP_User object for the current user in the session
56
         *
57
         * @var \WP_User $current_user
58
         */
59
        protected $current_user;
60

61
        /**
62
         * Stores the visibility value for the current piece of data
63
         *
64
         * @var string
65
         */
66
        protected $visibility;
67

68
        /**
69
         * The fields for the modeled object. This will be populated in the child class
70
         *
71
         * @var array<string,mixed>
72
         */
73
        public $fields;
74

75
        /**
76
         * Model constructor.
77
         *
78
         * @param string   $restricted_cap            The capability to check against to determine if
79
         *                                            the data should be restricted or not
80
         * @param string[] $allowed_restricted_fields The allowed fields if the data is in fact restricted
81
         * @param int|null $owner                     Database ID of the user that owns this piece of
82
         *                                            data to compare with the current user ID
83
         *
84
         * @return void
85
         * @throws \Exception Throws Exception.
86
         */
87
        protected function __construct( $restricted_cap = '', $allowed_restricted_fields = [], $owner = null ) {
518✔
88
                if ( empty( $this->data ) ) {
518✔
89
                        // translators: %s is the name of the model.
90
                        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() ) ) );
×
91
                }
92

93
                $this->restricted_cap            = $restricted_cap;
518✔
94
                $this->allowed_restricted_fields = $allowed_restricted_fields;
518✔
95
                $this->owner                     = $owner;
518✔
96
                $this->current_user              = wp_get_current_user();
518✔
97

98
                if ( 'private' === $this->get_visibility() ) {
518✔
99
                        return;
38✔
100
                }
101

102
                $this->init();
509✔
103
                $this->prepare_fields();
509✔
104
        }
105

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

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

131
        /**
132
         * Magic method to re-map where external calls go to look for properties on the child objects.
133
         * This is crucial to let objects modeled through this class work with the default field
134
         * resolver.
135
         *
136
         * @param string $key Name of the property that is trying to be accessed
137
         *
138
         * @return mixed|null
139
         */
140
        public function __get( $key ) {
507✔
141
                if ( ! array_key_exists( $key, $this->fields ) ) {
507✔
142
                        return null;
3✔
143
                }
144

145
                // If the property is a callable, we need to process it.
146
                if ( 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
                }
152

153
                return $this->fields[ $key ];
489✔
154
        }
155

156
        /**
157
         * Setup the global state before each field is resolved so the Model has the necessary context.
158
         *
159
         * @return void
160
         */
161
        public function setup() {
136✔
162
        }
136✔
163

164
        /**
165
         * Tear-down call that runs after each field is resolved.
166
         *
167
         * This can be used to reset state to where it was before the model was setup.
168
         *
169
         * @return void
170
         */
171
        public function tear_down() {
393✔
172
        }
393✔
173

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

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

192
                return $this->model_name;
518✔
193
        }
194

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

203
                        /**
204
                         * Filter for the capability to check against for restricted data
205
                         *
206
                         * @param string      $restricted_cap The capability to check against
207
                         * @param string      $model_name     Name of the model the filter is currently being executed in
208
                         * @param TData       $data           The un-modeled incoming data
209
                         * @param string|null $visibility     The visibility that has currently been set for the data at this point
210
                         * @param int|null    $owner          The user ID for the owner of this piece of data
211
                         * @param \WP_User $current_user The current user for the session
212
                         *
213
                         * @return string
214
                         */
215
                        $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✔
216

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

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

240
                        /**
241
                         * Filter to determine if the data should be considered private or not
242
                         *
243
                         * @param bool        $is_private   Whether the model is private
244
                         * @param string      $model_name   Name of the model the filter is currently being executed in
245
                         * @param TData       $data         The un-modeled incoming data
246
                         * @param string|null $visibility   The visibility that has currently been set for the data at this point
247
                         * @param int|null    $owner        The user ID for the owner of this piece of data
248
                         * @param \WP_User    $current_user The current user for the session
249
                         *
250
                         * @return bool
251
                         */
252
                        $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✔
253

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

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

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

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

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

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

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

336
                $clean_array = [];
509✔
337
                foreach ( $this->fields as $key => $data ) {
509✔
338
                        $clean_array[ $key ] = function () use ( $key, $data ) {
509✔
339
                                /**
340
                                 * Filter to short circuit the callback for any field on a type.
341
                                 *
342
                                 * Returning anything other than null will stop the callback for the field from executing,
343
                                 * and will return your data or execute your callback instead.
344
                                 *
345
                                 * @param mixed    $result       The data returned from the callback. Null by default.
346
                                 * @param string   $key          The name of the field on the type
347
                                 * @param string   $model_name   Name of the model the filter is currently being executed in
348
                                 * @param TData    $data         The un-modeled incoming data
349
                                 * @param string   $visibility   The visibility setting for this piece of data
350
                                 * @param int|null $owner        The user ID for the owner of this piece of data
351
                                 * @param \WP_User $current_user The current user for the session
352
                                 */
353
                                $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✔
354

355
                                if ( ! is_null( $pre ) ) {
507✔
356
                                        // If the pre filter returns a value, we use that instead of the callback.
357
                                        $result = $pre;
×
358
                                } else {
359
                                        $result = $this->prepare_field( $key, $data );
507✔
360
                                }
361

362
                                /**
363
                                 * Hook that fires after the data is returned for the field
364
                                 *
365
                                 * @param mixed    $result       The returned data for the field
366
                                 * @param string   $key          The name of the field on the type
367
                                 * @param string   $model_name   Name of the model the filter is currently being executed in
368
                                 * @param TData    $data         The un-modeled incoming data
369
                                 * @param string   $visibility   The visibility setting for this piece of data
370
                                 * @param int|null $owner        The user ID for the owner of this piece of data
371
                                 * @param \WP_User $current_user The current user for the session
372
                                 */
373
                                do_action( 'graphql_after_return_field_from_model', $result, $key, $this->get_model_name(), $this->data, $this->visibility, $this->owner, $this->current_user );
507✔
374

375
                                return $result;
507✔
376
                        };
509✔
377
                }
378

379
                $this->fields = $clean_array;
509✔
380
        }
381

382
        /**
383
         * Prepares an individual field for the model.
384
         *
385
         * @param string $field_name The name of the field on the type
386
         * @param TData  $field      The field data to prepare.
387
         *
388
         * @return TData
389
         */
390
        private function prepare_field( string $field_name, $field ) {
507✔
391
                $can_access_field = $this->current_user_can_access_field( $field_name, $field );
507✔
392

393
                // If the field is an array with a 'callback', use that as the callback.
394
                if ( is_array( $field ) && ! empty( $field['callback'] ) ) {
507✔
395
                        $field = $field['callback'];
17✔
396
                }
397

398
                // If the user doesn't have access to the field, sanitize it to null.
399
                if ( ! $can_access_field ) {
507✔
400
                        $field = null;
1✔
401
                }
402

403
                if ( is_callable( $field ) ) {
507✔
404
                        $this->setup();
507✔
405
                        $field = call_user_func( $field );
507✔
406
                        $this->tear_down();
507✔
407
                }
408

409
                /**
410
                 * Filter the data returned by the default callback for the field
411
                 *
412
                 * @param mixed    $field        The data returned from the callback
413
                 * @param string   $field_name   The name of the field on the type
414
                 * @param string   $model_name   Name of the model the filter is currently being executed in
415
                 * @param TData    $data         The un-modeled incoming data
416
                 * @param string   $visibility   The visibility setting for this piece of data
417
                 * @param int|null $owner        The user ID for the owner of this piece of data
418
                 * @param \WP_User $current_user The current user for the session
419
                 */
420
                return apply_filters( 'graphql_return_field_from_model', $field, $field_name, $this->get_model_name(), $this->data, $this->visibility, $this->owner, $this->current_user );
507✔
421
        }
422

423
        /**
424
         * Returns the capability to check for the field, or null if there is no capability set.
425
         *
426
         * @uses 'graphql_model_field_capability' to filter the capability to check for the field.
427
         *
428
         * @param string $field_name The name of the field to check
429
         * @param mixed  $field The original metadata for the field.
430
         */
431
        private function current_user_can_access_field( string $field_name, $field ): bool {
507✔
432
                $capability = '';
507✔
433

434
                // If the field metadata is an array, check for the capability key
435
                if ( is_array( $field ) && isset( $field['capability'] ) ) {
507✔
436
                        $capability = (string) $field['capability'];
17✔
437
                }
438

439
                /**
440
                 * Capability to check required for the field
441
                 *
442
                 * @param string   $capability   The capability to check against to return the field
443
                 * @param string   $field_name   The name of the field on the type
444
                 * @param string   $model_name   Name of the model the filter is currently being executed in
445
                 * @param TData    $data         The un-modeled incoming data
446
                 * @param string   $visibility   The visibility setting for this piece of data
447
                 * @param int|null $owner        The user ID for the owner of this piece of data
448
                 * @param \WP_User $current_user The current user for the session
449
                 */
450
                $capability = apply_filters( 'graphql_model_field_capability', $capability, $field_name, $this->get_model_name(), $this->data, $this->visibility, $this->owner, $this->current_user );
507✔
451

452
                if ( empty( $capability ) ) {
507✔
453
                        return true;
507✔
454
                }
455

456
                // @todo add support passing capability args.
457
                return current_user_can( $capability );
17✔
458
        }
459

460
        /**
461
         * Adds the model visibility fields to the data
462
         */
463
        private function add_model_visibility(): void {
509✔
464

465
                /**
466
                 * @todo: potentially abstract this out into a more central spot
467
                 */
468
                $this->fields['isPublic']     = function () {
509✔
469
                        return 'public' === $this->get_visibility();
×
470
                };
509✔
471
                $this->fields['isRestricted'] = function () {
509✔
472
                        return 'restricted' === $this->get_visibility();
1✔
473
                };
509✔
474
                $this->fields['isPrivate']    = function () {
509✔
475
                        return 'private' === $this->get_visibility();
6✔
476
                };
509✔
477
        }
478

479
        /**
480
         * Returns instance of the data fully modeled
481
         *
482
         * @return void
483
         */
484
        protected function prepare_fields() {
509✔
485
                if ( 'restricted' === $this->get_visibility() ) {
509✔
486
                        $this->restrict_fields();
176✔
487
                }
488

489
                /**
490
                 * Add support for the deprecated "graphql_return_modeled_data" filter.
491
                 *
492
                 * @param array<string,mixed>    $fields       The array of fields for the model
493
                 * @param string                 $model_name   Name of the model the filter is currently being executed in
494
                 * @param string                 $visibility   The visibility setting for this piece of data
495
                 * @param ?int                   $owner        The user ID for the owner of this piece of data
496
                 * @param \WP_User               $current_user The current user for the session
497
                 *
498
                 * @deprecated 1.7.0 use "graphql_model_prepare_fields" filter instead, which passes additional context to the filter
499
                 */
500
                $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✔
501

502
                /**
503
                 * Filter the array of fields for the Model before the object is hydrated with it
504
                 *
505
                 * @param array<string,mixed>    $fields       The array of fields for the model
506
                 * @param string                 $model_name   Name of the model the filter is currently being executed in
507
                 * @param TData                  $data         The un-modeled incoming data
508
                 * @param string                 $visibility   The visibility setting for this piece of data
509
                 * @param ?int                   $owner        The user ID for the owner of this piece of data
510
                 * @param \WP_User               $current_user The current user for the session
511
                 */
512
                $this->fields = apply_filters( 'graphql_model_prepare_fields', $this->fields, $this->get_model_name(), $this->data, $this->visibility, $this->owner, $this->current_user );
509✔
513
                $this->wrap_fields();
509✔
514
                $this->add_model_visibility();
509✔
515
        }
516

517
        /**
518
         * Given a string, and optional context, this decodes html entities if html_entity_decode is
519
         * enabled.
520
         *
521
         * @param string $str        The string to decode
522
         * @param string $field_name The name of the field being encoded
523
         * @param bool   $enabled    Whether decoding is enabled by default for the string passed in
524
         *
525
         * @return string
526
         */
527
        public function html_entity_decode( $str, $field_name, $enabled = false ) {
212✔
528

529
                /**
530
                 * Determine whether html_entity_decode should be applied to the string
531
                 *
532
                 * @param bool                   $enabled    Whether decoding is enabled by default for the string passed in
533
                 * @param string                 $str        The string to decode
534
                 * @param string                 $field_name The name of the field being encoded
535
                 * @param \WPGraphQL\Model\Model $model      The Model the field is being decoded on
536
                 */
537
                $decoding_enabled = apply_filters( 'graphql_html_entity_decoding_enabled', $enabled, $str, $field_name, $this );
212✔
538

539
                if ( false === $decoding_enabled ) {
212✔
540
                        return $str;
96✔
541
                }
542

543
                return html_entity_decode( $str, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401, 'UTF-8' );
175✔
544
        }
545

546
        /**
547
         * Filter the fields returned for the object
548
         *
549
         * @param string|string[]|null $fields The field or fields to build in the modeled object. Null to leave all fields.
550
         * @return void
551
         */
552
        public function filter( $fields ) {
×
553
                if ( is_string( $fields ) ) {
×
554
                        $fields = [ $fields ];
×
555
                }
556

557
                if ( is_array( $fields ) ) {
×
558
                        $this->fields = array_intersect_key( $this->fields, array_flip( $fields ) );
×
559
                }
560
        }
561

562
        /**
563
         * Initialized the object.
564
         *
565
         * @return void
566
         */
567
        abstract protected function init();
568
}
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

© 2025 Coveralls, Inc