*/ abstract class AbstractDataLoader { /** * Whether the loader should cache results or not. In some cases the loader may be used to just * get content but not bother with caching it. * * Default: true * * @var bool */ private $shouldCache = true; /** * This stores an array of items that have already been loaded * * @var array */ private $cached = []; /** * This stores an array of IDs that need to be loaded * * @var array */ private $buffer = []; /** * This stores a reference to the AppContext for the loader to make use of * * @var \WPGraphQL\AppContext */ protected $context; /** * AbstractDataLoader constructor. * * @param \WPGraphQL\AppContext $context */ public function __construct( AppContext $context ) { $this->context = $context; } /** * Given a Database ID, the particular loader will buffer it and resolve it deferred. * * @param mixed|int|string $database_id The database ID for a particular loader to load an object * * @return \GraphQL\Deferred|null * @throws \Exception * * @phpstan-return ($database_id is int|string ? \GraphQL\Deferred : null) */ public function load_deferred( $database_id ) { if ( empty( $database_id ) ) { return null; } $database_id = absint( $database_id ) ? absint( $database_id ) : sanitize_text_field( $database_id ); $this->buffer( [ $database_id ] ); return new Deferred( function () use ( $database_id ) { return $this->load( $database_id ); } ); } /** * Add keys to buffer to be loaded in single batch later. * * @param int[]|string[] $keys The keys of the objects to buffer * * @return $this * @throws \Exception */ public function buffer( array $keys ) { foreach ( $keys as $index => $key ) { $key = $this->key_to_scalar( $key ); if ( ! is_scalar( $key ) ) { throw new Exception( static::class . '::buffer expects all keys to be scalars, but key ' . 'at position ' . esc_html( $index ) . ' is ' . esc_html( Utils::printSafe( $keys ) . '. ' . $this->get_scalar_key_hint( $key ) ) ); } $this->buffer[ $key ] = 1; } return $this; } /** * Loads a key and returns value represented by this key. * Internally this method will load all currently buffered items and cache them locally. * * @param int|string|mixed $key * * @return ?TModel * @throws \Exception */ public function load( $key ) { $key = $this->key_to_scalar( $key ); if ( ! is_scalar( $key ) ) { throw new Exception( static::class . '::load expects key to be scalar, but got ' . esc_html( Utils::printSafe( $key ) . $this->get_scalar_key_hint( $key ) ) ); } if ( ! $this->shouldCache ) { $this->buffer = []; } $keys = [ $key ]; $this->buffer( $keys ); $result = $this->load_buffered(); return isset( $result[ $key ] ) ? $this->normalize_entry( $result[ $key ], $key ) : null; } /** * Adds the provided key and value to the cache. If the key already exists, no * change is made. Returns itself for method chaining. * * @param mixed $key * @param mixed $value * * @return $this * @throws \Exception */ public function prime( $key, $value ) { $key = $this->key_to_scalar( $key ); if ( ! is_scalar( $key ) ) { throw new Exception( static::class . '::prime is expecting scalar $key, but got ' . esc_html( Utils::printSafe( $key ) . $this->get_scalar_key_hint( $key ) ) ); } if ( null === $value ) { throw new Exception( static::class . '::prime is expecting non-null $value, but got null. Double-check for null or ' . ' use `clear` if you want to clear the cache' ); } if ( ! $this->get_cached( $key ) ) { /** * For adding third-party caching support. * Use this filter to store the queried value in a cache. * * @param mixed $value Queried object. * @param mixed $key Object key. * @param string $loader_class Loader classname. Use as a means of identified the loader. * @param mixed $loader Loader instance. */ $this->set_cached( $key, $value ); } return $this; } /** * Clears the value at `key` from the cache, if it exists. Returns itself for * method chaining. * * @param int[]|string[] $keys * * @return $this */ public function clear( array $keys ) { foreach ( $keys as $key ) { $key = $this->key_to_scalar( $key ); if ( isset( $this->cached[ $key ] ) ) { unset( $this->cached[ $key ] ); } } return $this; } /** * Clears the entire cache. To be used when some event results in unknown * invalidations across this particular `DataLoader`. Returns itself for * method chaining. * * @return \WPGraphQL\Data\Loader\AbstractDataLoader */ public function clear_all() { $this->cached = []; return $this; } /** * Loads multiple keys. Returns generator where each entry directly corresponds to entry in * $keys. If second argument $asArray is set to true, returns array instead of generator * * @param int[]|string[] $keys * @param bool $asArray * * @return \Generator|array * @throws \Exception */ public function load_many( array $keys, $asArray = false ) { if ( empty( $keys ) ) { return []; } if ( ! $this->shouldCache ) { $this->buffer = []; } $this->buffer( $keys ); $generator = $this->generate_many( $keys, $this->load_buffered() ); return $asArray ? iterator_to_array( $generator ) : $generator; } /** * Given an array of keys, this yields the object from the cached results * * @param int[]|string[] $keys The keys to generate results for * @param array $result The results for all keys * * @return \Generator */ private function generate_many( array $keys, array $result ) { foreach ( $keys as $key ) { $key = $this->key_to_scalar( $key ); yield isset( $result[ $key ] ) ? $this->normalize_entry( $result[ $key ], $key ) : null; } } /** * This checks to see if any items are in the buffer, and if there are this * executes the loaders `loadKeys` method to load the items and adds them * to the cache if necessary * * @return array * @throws \Exception */ private function load_buffered(): array { // Do not load previously-cached entries: $keysToLoad = []; foreach ( $this->buffer as $key => $unused ) { if ( ! $this->get_cached( $key ) ) { $keysToLoad[] = $key; } } $result = []; if ( ! empty( $keysToLoad ) ) { try { $loaded = $this->loadKeys( $keysToLoad ); } catch ( \Throwable $e ) { throw new Exception( 'Method ' . static::class . '::loadKeys is expected to return array, but it threw: ' . esc_html( $e->getMessage() ), 0, $e // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped ); } if ( ! is_array( $loaded ) ) { throw new Exception( 'Method ' . static::class . '::loadKeys is expected to return an array with keys ' . 'but got: ' . esc_html( Utils::printSafe( $loaded ) ) ); } if ( $this->shouldCache ) { foreach ( $loaded as $key => $value ) { $this->set_cached( $key, $value ); } } } // Re-include previously-cached entries to result: $result += array_intersect_key( $this->cached, $this->buffer ); $this->buffer = []; return $result; } /** * This helps to ensure null values aren't being loaded by accident. * * @param mixed $key */ private function get_scalar_key_hint( $key ): string { if ( null === $key ) { return ' Make sure to add additional checks for null values.'; } else { return ' Try overriding ' . self::class . '::key_to_scalar if your keys are composite.'; } } /** * For loaders that need to decode keys, this method can help with that. * For example, if we wanted to accept a list of RELAY style global IDs and pass them * to the loader, we could have the loader centrally decode the keys into their * integer values in the PostObjectLoader by overriding this method. * * @param int|string|mixed $key * * @return int|string */ protected function key_to_scalar( $key ) { return $key; } /** * @param mixed $entry The entry loaded from the dataloader to be used to generate a Model * @param mixed $key The Key used to identify the loaded entry * * @return TModel|null */ protected function normalize_entry( $entry, $key ) { $model = null; /** * This filter allows the model generated by the DataLoader to be filtered. * * Returning anything other than null here will bypass the default model generation * for an object. * * One example would be WooCommerce Products returning a custom Model for posts of post_type "product". * * @param null $model The filtered model to return. Default null * @param mixed $entry The entry loaded from the dataloader to be used to generate a Model * @param mixed $key The Key used to identify the loaded entry * @param \WPGraphQL\Data\Loader\AbstractDataLoader $abstract_data_loader The AbstractDataLoader instance * * @hookGroup models * @since 0.0.5 */ $pre_get_model = apply_filters( 'graphql_dataloader_pre_get_model', $model, $entry, $key, $this ); /** * If a Model has been pre-loaded via filter, return it and skip the */ if ( ! empty( $pre_get_model ) ) { $model = $pre_get_model; } else { $model = $this->get_model( $entry, $key ); } if ( $model instanceof Model && 'private' === $model->get_visibility() ) { return null; } /** * Filter the model before returning. * * @param mixed $model The Model to be returned by the loader * @param mixed $entry The entry loaded by dataloader that was used to create the Model * @param mixed $key The Key that was used to load the entry * @param \WPGraphQL\Data\Loader\AbstractDataLoader $loader The AbstractDataLoader Instance * * @hookGroup models * @since 0.0.5 */ return apply_filters( 'graphql_dataloader_get_model', $model, $entry, $key, $this ); } /** * Returns a cached data object by key. * * @param int|string $key Key. * * @return mixed */ protected function get_cached( $key ) { $value = null; if ( isset( $this->cached[ $key ] ) ) { $value = $this->cached[ $key ]; } /** * Use this filter to retrieving cached data objects from third-party caching system. * * @param mixed $value Value to be cached. * @param int|string $key Key identifying object. * @param string $loader_class Loader class name. * @param mixed $loader Loader instance. * * @hookGroup models * @since 0.0.5 */ $value = apply_filters( 'graphql_dataloader_get_cached', $value, $key, static::class, $this ); if ( $value && ! isset( $this->cached[ $key ] ) ) { $this->cached[ $key ] = $value; } return $value; } /** * Caches a data object by key. * * @param int|string $key Key. * @param mixed $value Data object. * * @return void */ protected function set_cached( $key, $value ) { /** * Use this filter to store entry in a third-party caching system. * * @param mixed $value Value to be cached. * @param mixed $key Key identifying object. * @param string $loader_class Loader class name. * @param mixed $loader Loader instance. * * @hookGroup models * @since 0.0.5 */ $this->cached[ $key ] = apply_filters( 'graphql_dataloader_set_cached', $value, $key, static::class, $this ); } /** * If the loader needs to do any tweaks between getting raw data from the DB and caching, * this can be overridden by the specific loader and used for transformations, etc. * * @param mixed $entry The entry data to be used to generate a Model. * @param mixed $key The Key to identify the entry by. * * @return ?TModel */ protected function get_model( $entry, $key ) { return $entry; } /** * Given array of keys, loads and returns a map consisting of keys from `keys` array and loaded * values * * Note that order of returned values must match exactly the order of keys. * If some entry is not available for given key - it must include null for the missing key. * * For example: * loadKeys(['a', 'b', 'c']) -> ['a' => 'value1, 'b' => null, 'c' => 'value3'] * * @param int[]|string[] $keys * * @return array */ abstract protected function loadKeys( array $keys ); // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid -- @todo deprecate for `::load_keys()` /** * @todo remove in 3.0.0 * @deprecated Use load_many instead * @codeCoverageIgnore * * @param int[]|string[] $keys * @param bool $asArray * * @return \Generator|array * @throws \Exception */ public function loadMany( array $keys, $asArray = false ) { _doing_it_wrong( __METHOD__, sprintf( // translators: %s is the method name esc_html__( 'This method will be removed in the next major release. Use %s instead.', 'wp-graphql' ), static::class . '::load_many()' ), '0.8.4' ); return $this->load_many( $keys, $asArray ); } /** * @todo remove in 3.0.0 * @deprecated in favor of clear_all * @codeCoverageIgnore * * @return \WPGraphQL\Data\Loader\AbstractDataLoader */ public function clearAll() { _doing_it_wrong( __METHOD__, sprintf( // translators: %s is the method name esc_html__( 'This method will be removed in the next major release. Use %s instead.', 'wp-graphql' ), static::class . '::clear_all()' ), '0.8.4' ); return $this->clear_all(); } /** * @todo remove in 3.0.0 * @deprecated Use key_to_scalar instead * @codeCoverageIgnore * * @param int|string|mixed $key * @return int|string */ protected function keyToScalar( $key ) { _doing_it_wrong( __METHOD__, sprintf( // translators: %s is the method name esc_html__( 'This method will be removed in the next major release. Use %s instead.', 'wp-graphql' ), static::class . '::key_to_scalar()' ), '0.8.4' ); return $this->key_to_scalar( $key ); } }