├── .php-cs-fixer.php ├── LICENSE ├── README.md ├── composer.json ├── lib └── promise-adapter │ ├── LICENSE │ ├── README.md │ ├── docs │ └── usage.md │ └── src │ ├── Adapter │ ├── GuzzleHttpPromiseAdapter.php │ ├── ReactPromiseAdapter.php │ └── WebonyxGraphQLSyncPromiseAdapter.php │ └── PromiseAdapterInterface.php └── src ├── CacheMap.php ├── DataLoader.php ├── DataLoaderInterface.php ├── Option.php └── Promise └── Adapter └── Webonyx └── GraphQL └── SyncPromiseAdapter.php /.php-cs-fixer.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | use Symfony\CS\Fixer\Contrib\HeaderCommentFixer; 13 | 14 | $header = << 18 | 19 | For the full copyright and license information, please view the LICENSE 20 | file that was distributed with this source code. 21 | EOF; 22 | 23 | return (new PhpCsFixer\Config()) 24 | ->setRules([ 25 | '@PSR2' => true, 26 | 'array_syntax' => ['syntax' => 'short'], 27 | 'no_unreachable_default_argument_value' => false, 28 | 'heredoc_to_nowdoc' => false, 29 | 'header_comment' => ['header' => $header], 30 | ]) 31 | ->setRiskyAllowed(true) 32 | ->setFinder( 33 | PhpCsFixer\Finder::create() 34 | ->in(__DIR__) 35 | ->exclude(['vendor']) 36 | ) 37 | ; 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Overblog 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DataLoaderPHP 2 | 3 | DataLoaderPHP is a generic utility to be used as part of your application's data 4 | fetching layer to provide a simplified and consistent API over various remote 5 | data sources such as databases or web services via batching and caching. 6 | 7 | [![GitHub Actions][GA master image]][GA master] 8 | [![Code Coverage][Coverage image]][CodeCov Master] 9 | [![Latest Stable Version](https://poser.pugx.org/overblog/dataloader-php/version)](https://packagist.org/packages/overblog/dataloader-php) 10 | 11 | ## Requirements 12 | 13 | This library requires PHP >= 7.3 to work. 14 | 15 | ## Getting Started 16 | 17 | First, install DataLoaderPHP using composer. 18 | 19 | ```sh 20 | composer require "overblog/dataloader-php" 21 | ``` 22 | 23 | To get started, create a `DataLoader` object. 24 | 25 | ## Batching 26 | 27 | Batching is not an advanced feature, it's DataLoader's primary feature. 28 | Create loaders by providing a batch loading function. 29 | 30 | 31 | ```php 32 | use Overblog\DataLoader\DataLoader; 33 | 34 | $myBatchGetUsers = function ($keys) { /* ... */ }; 35 | $promiseAdapter = new MyPromiseAdapter(); 36 | 37 | $userLoader = new DataLoader($myBatchGetUsers, $promiseAdapter); 38 | ``` 39 | 40 | A batch loading callable / callback accepts an Array of keys, and returns a Promise which 41 | resolves to an Array of values. 42 | 43 | Then load individual values from the loader. DataLoaderPHP will coalesce all 44 | individual loads which occur within a single frame of execution (using `await` method) 45 | and then call your batch function with all requested keys. 46 | 47 | ```php 48 | $userLoader->load(1) 49 | ->then(function ($user) use ($userLoader) { return $userLoader->load($user->invitedByID); }) 50 | ->then(function ($invitedBy) { echo "User 1 was invited by $invitedBy"; }); 51 | 52 | // Elsewhere in your application 53 | $userLoader->load(2) 54 | ->then(function ($user) use ($userLoader) { return $userLoader->load($user->invitedByID); }) 55 | ->then(function ($invitedBy) { echo "User 2 was invited by $invitedBy"; }); 56 | 57 | // Synchronously waits on the promise to complete, if not using EventLoop. 58 | $userLoader->await(); // or `DataLoader::await()` 59 | ``` 60 | A naive application may have issued four round-trips to a backend for the 61 | required information, but with DataLoaderPHP this application will make at most 62 | two. 63 | 64 | DataLoaderPHP allows you to decouple unrelated parts of your application without 65 | sacrificing the performance of batch data-loading. While the loader presents an 66 | API that loads individual values, all concurrent requests will be coalesced and 67 | presented to your batch loading function. This allows your application to safely 68 | distribute data fetching requirements throughout your application and maintain 69 | minimal outgoing data requests. 70 | 71 | #### Batch Function 72 | 73 | A batch loading function accepts an Array of keys, and returns a Promise which 74 | resolves to an Array of values. There are a few constraints that must be upheld: 75 | 76 | * The Array of values must be the same length as the Array of keys. 77 | * Each index in the Array of values must correspond to the same index in the Array of keys. 78 | 79 | For example, if your batch function was provided the Array of keys: `[ 2, 9, 6, 1 ]`, 80 | and loading from a back-end service returned the values: 81 | 82 | ```php 83 | [ 84 | ['id' => 9, 'name' => 'Chicago'], 85 | ['id' => 1, 'name' => 'New York'], 86 | ['id' => 2, 'name' => 'San Francisco'] 87 | ] 88 | ``` 89 | 90 | Our back-end service returned results in a different order than we requested, likely 91 | because it was more efficient for it to do so. Also, it omitted a result for key `6`, 92 | which we can interpret as no value existing for that key. 93 | 94 | To uphold the constraints of the batch function, it must return an Array of values 95 | the same length as the Array of keys, and re-order them to ensure each index aligns 96 | with the original keys `[ 2, 9, 6, 1 ]`: 97 | 98 | ```php 99 | [ 100 | ['id' => 2, 'name' => 'San Francisco'], 101 | ['id' => 9, 'name' => 'Chicago'], 102 | null, 103 | ['id' => 1, 'name' => 'New York'] 104 | ] 105 | ``` 106 | 107 | 108 | ### Caching (current PHP instance) 109 | 110 | DataLoader provides a memoization cache for all loads which occur in a single 111 | request to your application. After `->load()` is called once with a given key, 112 | the resulting value is cached to eliminate redundant loads. 113 | 114 | In addition to relieving pressure on your data storage, caching results per-request 115 | also creates fewer objects which may relieve memory pressure on your application: 116 | 117 | ```php 118 | $userLoader = new DataLoader(...); 119 | $promise1A = $userLoader->load(1); 120 | $promise1B = $userLoader->load(1); 121 | var_dump($promise1A === $promise1B); // bool(true) 122 | ``` 123 | 124 | #### Clearing Cache 125 | 126 | In certain uncommon cases, clearing the request cache may be necessary. 127 | 128 | The most common example when clearing the loader's cache is necessary is after 129 | a mutation or update within the same request, when a cached value could be out of 130 | date and future loads should not use any possibly cached value. 131 | 132 | Here's a simple example using SQL UPDATE to illustrate. 133 | 134 | ```php 135 | use Overblog\DataLoader\DataLoader; 136 | 137 | // Request begins... 138 | $userLoader = new DataLoader(...); 139 | 140 | // And a value happens to be loaded (and cached). 141 | $userLoader->load(4)->then(...); 142 | 143 | // A mutation occurs, invalidating what might be in cache. 144 | $sql = 'UPDATE users WHERE id=4 SET username="zuck"'; 145 | if (true === $conn->query($sql)) { 146 | $userLoader->clear(4); 147 | } 148 | 149 | // Later the value load is loaded again so the mutated data appears. 150 | $userLoader->load(4)->then(...); 151 | 152 | // Request completes. 153 | ``` 154 | 155 | #### Caching Errors 156 | 157 | If a batch load fails (that is, a batch function throws or returns a rejected 158 | Promise), then the requested values will not be cached. However if a batch 159 | function returns an `Error` instance for an individual value, that `Error` will 160 | be cached to avoid frequently loading the same `Error`. 161 | 162 | In some circumstances you may wish to clear the cache for these individual Errors: 163 | 164 | ```php 165 | $userLoader->load(1)->then(null, function ($exception) { 166 | if (/* determine if error is transient */) { 167 | $userLoader->clear(1); 168 | } 169 | throw $exception; 170 | }); 171 | ``` 172 | 173 | #### Disabling Cache 174 | 175 | In certain uncommon cases, a DataLoader which *does not* cache may be desirable. 176 | Calling `new DataLoader(myBatchFn, new Option(['cache' => false ]))` will ensure that every 177 | call to `->load()` will produce a *new* Promise, and requested keys will not be 178 | saved in memory. 179 | 180 | However, when the memoization cache is disabled, your batch function will 181 | receive an array of keys which may contain duplicates! Each key will be 182 | associated with each call to `->load()`. Your batch loader should provide a value 183 | for each instance of the requested key. 184 | 185 | For example: 186 | 187 | ```php 188 | $myLoader = new DataLoader(function ($keys) { 189 | echo json_encode($keys); 190 | return someBatchLoadFn($keys); 191 | }, $promiseAdapter, new Option(['cache' => false ])); 192 | 193 | $myLoader->load('A'); 194 | $myLoader->load('B'); 195 | $myLoader->load('A'); 196 | 197 | // [ 'A', 'B', 'A' ] 198 | ``` 199 | 200 | More complex cache behavior can be achieved by calling `->clear()` or `->clearAll()` 201 | rather than disabling the cache completely. For example, this DataLoader will 202 | provide unique keys to a batch function due to the memoization cache being 203 | enabled, but will immediately clear its cache when the batch function is called 204 | so later requests will load new values. 205 | 206 | ```php 207 | $myLoader = new DataLoader(function($keys) use ($identityLoader) { 208 | $identityLoader->clearAll(); 209 | return someBatchLoadFn($keys); 210 | }, $promiseAdapter); 211 | ``` 212 | 213 | 214 | ## API 215 | 216 | #### class DataLoader 217 | 218 | DataLoaderPHP creates a public API for loading data from a particular 219 | data back-end with unique keys such as the `id` column of a SQL table or 220 | document name in a MongoDB database, given a batch loading function. 221 | 222 | Each `DataLoaderPHP` instance contains a unique memoized cache. Use caution when 223 | used in long-lived applications or those which serve many users with different 224 | access permissions and consider creating a new instance per web request. 225 | 226 | ##### `new DataLoader(callable $batchLoadFn, PromiseAdapterInterface $promiseAdapter [, Option $options])` 227 | 228 | Create a new `DataLoaderPHP` given a batch loading instance and options. 229 | 230 | - *$batchLoadFn*: A callable / callback which accepts an Array of keys, and returns a Promise which resolves to an Array of values. 231 | - *$promiseAdapter*: Any object that implements `Overblog\PromiseAdapter\PromiseAdapterInterface`. (see [Overblog/Promise-Adapter](./lib/promise-adapter/docs/usage.md)) 232 | - *$options*: An optional object of options: 233 | 234 | - *batch*: Default `true`. Set to `false` to disable batching, instead 235 | immediately invoking `batchLoadFn` with a single load key. 236 | 237 | - *maxBatchSize*: Default `Infinity`. Limits the number of items that get 238 | passed in to the `batchLoadFn`. 239 | 240 | - *cache*: Default `true`. Set to `false` to disable caching, instead 241 | creating a new Promise and new key in the `batchLoadFn` for every load. 242 | 243 | - *cacheKeyFn*: A function to produce a cache key for a given load key. 244 | Defaults to `key`. Useful to provide when an objects are keys 245 | and two similarly shaped objects should be considered equivalent. 246 | 247 | - *cacheMap*: An instance of `CacheMap` to be 248 | used as the underlying cache for this loader. Default `new CacheMap()`. 249 | 250 | ##### `load($key)` 251 | 252 | Loads a key, returning a `Promise` for the value represented by that key. 253 | 254 | - *$key*: An key value to load. 255 | 256 | ##### `loadMany($keys)` 257 | 258 | Loads multiple keys, promising an array of values: 259 | 260 | ```php 261 | list($a, $b) = DataLoader::await($myLoader->loadMany(['a', 'b'])); 262 | ``` 263 | 264 | This is equivalent to the more verbose: 265 | 266 | ```php 267 | list($a, $b) = DataLoader::await(\React\Promise\all([ 268 | $myLoader->load('a'), 269 | $myLoader->load('b') 270 | ])); 271 | ``` 272 | 273 | - *$keys*: An array of key values to load. 274 | 275 | ##### `clear($key)` 276 | 277 | Clears the value at `$key` from the cache, if it exists. Returns itself for 278 | method chaining. 279 | 280 | - *$key*: An key value to clear. 281 | 282 | ##### `clearAll()` 283 | 284 | Clears the entire cache. To be used when some event results in unknown 285 | invalidations across this particular `DataLoaderPHP`. Returns itself for 286 | method chaining. 287 | 288 | ##### `prime($key, $value)` 289 | 290 | Primes the cache with the provided key and value. If the key already exists, no 291 | change is made. (To forcefully prime the cache, clear the key first with 292 | `$loader->clear($key)->prime($key, $value)`. Returns itself for method chaining. 293 | 294 | ##### `static await([$promise][, $unwrap])` 295 | 296 | You can synchronously force promises to complete using DataLoaderPHP's await method. 297 | When an await function is invoked it is expected to deliver a value to the promise or reject the promise. 298 | Await method process all waiting promise in all dataLoaderPHP instances. 299 | 300 | - *$promise*: Optional promise to complete. 301 | 302 | - *$unwrap*: controls whether or not the value of the promise is returned for a fulfilled promise 303 | or if an exception is thrown if the promise is rejected. Default `true`. 304 | 305 | ## Using with Webonyx/GraphQL 306 | 307 | DataLoader pairs nicely well with [Webonyx/GraphQL](https://github.com/webonyx/graphql-php). GraphQL fields are 308 | designed to be stand-alone functions. Without a caching or batching mechanism, 309 | it's easy for a naive GraphQL server to issue new database requests each time a 310 | field is resolved. 311 | 312 | Consider the following GraphQL request: 313 | 314 | ```graphql 315 | { 316 | me { 317 | name 318 | bestFriend { 319 | name 320 | } 321 | friends(first: 5) { 322 | name 323 | bestFriend { 324 | name 325 | } 326 | } 327 | } 328 | } 329 | ``` 330 | 331 | Naively, if `me`, `bestFriend` and `friends` each need to request the backend, 332 | there could be at most 13 database requests! 333 | 334 | When using DataLoader, we could define the `User` type 335 | at most 4 database requests, 336 | and possibly fewer if there are cache hits. 337 | 338 | ```php 339 | 'User', 360 | 'fields' => function () use (&$userType, $userLoader, $dbh) { 361 | return [ 362 | 'name' => ['type' => Type::string()], 363 | 'bestFriend' => [ 364 | 'type' => $userType, 365 | 'resolve' => function ($user) use ($userLoader) { 366 | $userLoader->load($user['bestFriendID']); 367 | } 368 | ], 369 | 'friends' => [ 370 | 'args' => [ 371 | 'first' => ['type' => Type::int() ], 372 | ], 373 | 'type' => Type::listOf($userType), 374 | 'resolve' => function ($user, $args) use ($userLoader, $dbh) { 375 | $sth = $dbh->prepare('SELECT toID FROM friends WHERE fromID=:userID LIMIT :first'); 376 | $sth->bindParam(':userID', $user['id'], PDO::PARAM_INT); 377 | $sth->bindParam(':first', $args['first'], PDO::PARAM_INT); 378 | $friendIDs = $sth->execute(); 379 | 380 | return $userLoader->loadMany($friendIDs); 381 | } 382 | ] 383 | ]; 384 | } 385 | ]); 386 | ``` 387 | You can also see [an example](https://github.com/mcg-web/sandbox-dataloader-graphql-php). 388 | 389 | ## Using with Symfony 390 | 391 | See the [bundle](https://github.com/overblog/dataloader-bundle). 392 | 393 | ## Credits 394 | 395 | Overblog/DataLoaderPHP is a port of [dataLoader NodeJS version](https://github.com/facebook/dataloader) 396 | by [Facebook](https://github.com/facebook). 397 | 398 | Also, large parts of the documentation have been ported from the dataLoader NodeJS version 399 | [Docs](https://github.com/facebook/dataloader/blob/master/README.md). 400 | 401 | ## License 402 | 403 | Overblog/DataLoaderPHP is released under the [MIT](https://github.com/overblog/dataloader-php/blob/master/LICENSE) license. 404 | 405 | [Coverage image]: https://codecov.io/gh/overblog/dataloader-php/branch/master/graph/badge.svg 406 | [CodeCov Master]: https://codecov.io/gh/overblog/dataloader-php/branch/master 407 | [GA master]: https://github.com/overblog/dataloader-php/actions?query=workflow%3A%22CI%22+branch%3Amaster 408 | [GA master image]: https://github.com/overblog/dataloader-php/workflows/CI/badge.svg 409 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "overblog/dataloader-php", 3 | "type": "library", 4 | "license": "MIT", 5 | "description": "DataLoaderPhp is a generic utility to be used as part of your application's data fetching layer to provide a simplified and consistent API over various remote data sources such as databases or web services via batching and caching.", 6 | "keywords": ["dataLoader", "caching", "batching"], 7 | "config" : { 8 | "sort-packages": true 9 | }, 10 | "autoload": { 11 | "psr-4": { 12 | "Overblog\\DataLoader\\": "src/", 13 | "Overblog\\PromiseAdapter\\": "lib/promise-adapter/src/" 14 | } 15 | }, 16 | "autoload-dev": { 17 | "psr-4": { 18 | "Overblog\\DataLoader\\Test\\": "tests/", 19 | "Overblog\\PromiseAdapter\\Test\\": "lib/promise-adapter/tests/" 20 | } 21 | }, 22 | "replace": { 23 | "overblog/promise-adapter": "self.version" 24 | }, 25 | "require": { 26 | "php": "^8.1" 27 | }, 28 | "require-dev": { 29 | "guzzlehttp/promises": "^1.5.0 || ^2.0.0", 30 | "phpunit/phpunit": "^10.3", 31 | "react/promise": "^2.8.0", 32 | "webonyx/graphql-php": "^15.0" 33 | }, 34 | "suggest": { 35 | "guzzlehttp/promises": "To use with Guzzle promise", 36 | "react/promise": "To use with ReactPhp promise", 37 | "webonyx/graphql-php": "To use with Webonyx GraphQL native promise" 38 | }, 39 | "extra": { 40 | "branch-alias": { 41 | "dev-master": "0.6-dev" 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /lib/promise-adapter/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Overblog 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/promise-adapter/README.md: -------------------------------------------------------------------------------- 1 | # PromiseAdapter 2 | 3 | This library tries to create a simple promise adapter standard while waiting for a psr. 4 | It Comes out of the box with adapter for [ReactPhp/Promise](https://github.com/reactphp/promise) and 5 | [Guzzle/Promises](https://github.com/guzzle/promises). 6 | 7 | [![Build Status](https://travis-ci.org/overblog/promise-adapter.svg?branch=master)](https://travis-ci.org/overblog/promise-adapter) 8 | [![Coverage Status](https://coveralls.io/repos/github/overblog/promise-adapter/badge.svg?branch=master)](https://coveralls.io/github/overblog/promise-adapter?branch=master) 9 | [![Latest Stable Version](https://poser.pugx.org/overblog/promise-adapter/version)](https://packagist.org/packages/overblog/promise-adapter) 10 | [![License](https://poser.pugx.org/overblog/promise-adapter/license)](https://packagist.org/packages/overblog/promise-adapter) 11 | 12 | ## Installation 13 | 14 | First, install PromiseAdapter using composer. 15 | 16 | ```sh 17 | composer require "overblog/promise-adapter" 18 | ``` 19 | 20 | # Usage 21 | 22 | see [here](./docs/usage.md) 23 | 24 | ## License 25 | 26 | Overblog/PromiseAdapter is released under the [MIT](https://github.com/overblog/promise-adapter/blob/master/LICENSE) license. 27 | -------------------------------------------------------------------------------- /lib/promise-adapter/docs/usage.md: -------------------------------------------------------------------------------- 1 | # Promise adapter usage 2 | 3 | ## Optional requirements 4 | 5 | Optional to use Guzzle: 6 | 7 | ```sh 8 | composer require "guzzlehttp/promises" 9 | ``` 10 | 11 | Optional to use ReactPhp: 12 | 13 | ```sh 14 | composer require "react/promise" 15 | ``` 16 | 17 | ## Supported Adapter 18 | 19 | *Guzzle*: `Overblog\PromiseAdapter\Adapter\GuzzleHttpPromiseAdapter` 20 | 21 | *ReactPhp*: `Overblog\PromiseAdapter\Adapter\ReactPromiseAdapter` 22 | 23 | To use a custom Promise lib you can implement `Overblog\PromiseAdapter\PromiseAdapterInterface` 24 | -------------------------------------------------------------------------------- /lib/promise-adapter/src/Adapter/GuzzleHttpPromiseAdapter.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Overblog\PromiseAdapter\Adapter; 13 | 14 | use GuzzleHttp\Promise\Create; 15 | use GuzzleHttp\Promise\FulfilledPromise; 16 | use GuzzleHttp\Promise\Promise; 17 | use GuzzleHttp\Promise\PromiseInterface; 18 | use GuzzleHttp\Promise\RejectedPromise; 19 | use GuzzleHttp\Promise\Utils; 20 | use Overblog\PromiseAdapter\PromiseAdapterInterface; 21 | 22 | /** 23 | * @implements PromiseAdapterInterface 24 | */ 25 | class GuzzleHttpPromiseAdapter implements PromiseAdapterInterface 26 | { 27 | /** 28 | * {@inheritdoc} 29 | * 30 | * @return Promise 31 | */ 32 | public function create(&$resolve = null, &$reject = null, ?callable $canceller = null) 33 | { 34 | $queue = Utils::queue(); 35 | $promise = new Promise([$queue, 'run'], $canceller); 36 | 37 | $reject = [$promise, 'reject']; 38 | $resolve = [$promise, 'resolve']; 39 | 40 | return $promise; 41 | } 42 | 43 | /** 44 | * {@inheritdoc} 45 | * 46 | * @return FulfilledPromise a full filed Promise 47 | */ 48 | public function createFulfilled($promiseOrValue = null) 49 | { 50 | $promise = Create::promiseFor($promiseOrValue); 51 | 52 | return $promise; 53 | } 54 | 55 | /** 56 | * {@inheritdoc} 57 | * 58 | * @return RejectedPromise a rejected promise 59 | */ 60 | public function createRejected($reason) 61 | { 62 | $promise = Create::rejectionFor($reason); 63 | 64 | return $promise; 65 | } 66 | 67 | /** 68 | * {@inheritdoc} 69 | * 70 | * @return Promise 71 | */ 72 | public function createAll($promisesOrValues) 73 | { 74 | $promise = empty($promisesOrValues) ? $this->createFulfilled($promisesOrValues) : Utils::all($promisesOrValues); 75 | 76 | return $promise; 77 | } 78 | 79 | /** 80 | * {@inheritdoc} 81 | */ 82 | public function isPromise($value, $strict = false) 83 | { 84 | $isStrictPromise = $value instanceof PromiseInterface; 85 | 86 | if ($strict) { 87 | return $isStrictPromise; 88 | } 89 | 90 | return $isStrictPromise || is_callable([$value, 'then']); 91 | } 92 | 93 | /** 94 | * {@inheritdoc} 95 | */ 96 | public function await($promise = null, $unwrap = false) 97 | { 98 | $resolvedValue = null; 99 | 100 | if (null !== $promise) { 101 | $exception = null; 102 | if (!static::isPromise($promise)) { 103 | throw new \InvalidArgumentException(sprintf('The "%s" method must be called with a Promise ("then" method).', __METHOD__)); 104 | } 105 | 106 | /** @var Promise $promise */ 107 | $promise->then(function ($values) use (&$resolvedValue) { 108 | $resolvedValue = $values; 109 | }, function ($reason) use (&$exception) { 110 | $exception = $reason; 111 | }); 112 | Utils::queue()->run(); 113 | 114 | if ($exception instanceof \Exception) { 115 | if (!$unwrap) { 116 | return $exception; 117 | } 118 | throw $exception; 119 | } 120 | } else { 121 | Utils::queue()->run(); 122 | } 123 | 124 | return $resolvedValue; 125 | } 126 | 127 | /** 128 | * Cancel a promise 129 | * 130 | * @param PromiseInterface $promise 131 | * {@inheritdoc} 132 | */ 133 | public function cancel($promise) 134 | { 135 | if (!static::isPromise($promise, true)) { 136 | throw new \InvalidArgumentException(sprintf('The "%s" method must be called with a compatible Promise.', __METHOD__)); 137 | } 138 | $promise->cancel(); 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /lib/promise-adapter/src/Adapter/ReactPromiseAdapter.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Overblog\PromiseAdapter\Adapter; 13 | 14 | use Overblog\PromiseAdapter\PromiseAdapterInterface; 15 | use React\Promise\CancellablePromiseInterface; 16 | use React\Promise\Deferred; 17 | use React\Promise\FulfilledPromise; 18 | use React\Promise\Promise; 19 | use React\Promise\PromiseInterface; 20 | use React\Promise\RejectedPromise; 21 | 22 | /** 23 | * @implements PromiseAdapterInterface 24 | */ 25 | class ReactPromiseAdapter implements PromiseAdapterInterface 26 | { 27 | /** 28 | * {@inheritdoc} 29 | * 30 | * @return Promise 31 | */ 32 | public function create(&$resolve = null, &$reject = null, ?callable $canceller = null) 33 | { 34 | $deferred = new Deferred($canceller); 35 | 36 | $reject = [$deferred, 'reject']; 37 | $resolve = [$deferred, 'resolve']; 38 | 39 | return $deferred->promise(); 40 | } 41 | 42 | /** 43 | * {@inheritdoc} 44 | * 45 | * @return FulfilledPromise a full filed Promise 46 | */ 47 | public function createFulfilled($promiseOrValue = null) 48 | { 49 | return \React\Promise\resolve($promiseOrValue); 50 | } 51 | 52 | /** 53 | * {@inheritdoc} 54 | * 55 | * @return RejectedPromise a rejected promise 56 | */ 57 | public function createRejected($reason) 58 | { 59 | return \React\Promise\reject($reason); 60 | } 61 | 62 | /** 63 | * {@inheritdoc} 64 | * 65 | * @return Promise 66 | */ 67 | public function createAll($promisesOrValues) 68 | { 69 | return \React\Promise\all($promisesOrValues); 70 | } 71 | 72 | /** 73 | * {@inheritdoc} 74 | */ 75 | public function isPromise($value, $strict = false) 76 | { 77 | $isStrictPromise = $value instanceof PromiseInterface; 78 | 79 | if ($strict) { 80 | return $isStrictPromise; 81 | } 82 | 83 | return $isStrictPromise || is_callable([$value, 'then']); 84 | } 85 | 86 | /** 87 | * {@inheritdoc} 88 | */ 89 | public function await($promise = null, $unwrap = false) 90 | { 91 | if (null === $promise) { 92 | return null; 93 | } 94 | $wait = true; 95 | $resolvedValue = null; 96 | $exception = null; 97 | if (!static::isPromise($promise)) { 98 | throw new \InvalidArgumentException(sprintf('The "%s" method must be called with a Promise ("then" method).', __METHOD__)); 99 | } 100 | $promise->then(function ($values) use (&$resolvedValue, &$wait) { 101 | $resolvedValue = $values; 102 | $wait = false; 103 | }, function ($reason) use (&$exception, &$wait) { 104 | $exception = $reason; 105 | $wait = false; 106 | }); 107 | 108 | if ($exception instanceof \Exception) { 109 | if (!$unwrap) { 110 | return $exception; 111 | } 112 | throw $exception; 113 | } 114 | 115 | return $resolvedValue; 116 | } 117 | 118 | /** 119 | * Cancel a promise 120 | * 121 | * @param CancellablePromiseInterface $promise 122 | */ 123 | public function cancel($promise) 124 | { 125 | if (!$promise instanceof CancellablePromiseInterface) { 126 | throw new \InvalidArgumentException(sprintf('The "%s" method must be called with a compatible Promise.', __METHOD__)); 127 | } 128 | $promise->cancel(); 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /lib/promise-adapter/src/Adapter/WebonyxGraphQLSyncPromiseAdapter.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Overblog\PromiseAdapter\Adapter; 13 | 14 | use GraphQL\Deferred; 15 | use GraphQL\Executor\Promise\Adapter\SyncPromise; 16 | use GraphQL\Executor\Promise\Adapter\SyncPromiseAdapter; 17 | use GraphQL\Executor\Promise\Promise; 18 | use Overblog\PromiseAdapter\PromiseAdapterInterface; 19 | 20 | /** 21 | * @implements PromiseAdapterInterface 22 | */ 23 | class WebonyxGraphQLSyncPromiseAdapter implements PromiseAdapterInterface 24 | { 25 | /** @var callable[] */ 26 | private $cancellers = []; 27 | 28 | /** 29 | * @var SyncPromiseAdapter 30 | */ 31 | private $webonyxPromiseAdapter; 32 | 33 | public function __construct(?SyncPromiseAdapter $webonyxPromiseAdapter = null) 34 | { 35 | $webonyxPromiseAdapter = $webonyxPromiseAdapter?:new SyncPromiseAdapter(); 36 | $this->setWebonyxPromiseAdapter($webonyxPromiseAdapter); 37 | } 38 | 39 | /** 40 | * @return SyncPromiseAdapter 41 | */ 42 | public function getWebonyxPromiseAdapter() 43 | { 44 | return $this->webonyxPromiseAdapter; 45 | } 46 | 47 | /** 48 | * @param SyncPromiseAdapter $webonyxPromiseAdapter 49 | */ 50 | public function setWebonyxPromiseAdapter(SyncPromiseAdapter $webonyxPromiseAdapter) 51 | { 52 | $this->webonyxPromiseAdapter = $webonyxPromiseAdapter; 53 | } 54 | 55 | /** 56 | * {@inheritdoc} 57 | */ 58 | public function create(&$resolve = null, &$reject = null, ?callable $canceller = null) 59 | { 60 | $promise = $this->webonyxPromiseAdapter->create(function ($res, $rej) use (&$resolve, &$reject) { 61 | $resolve = $res; 62 | $reject = $rej; 63 | }); 64 | $this->cancellers[spl_object_hash($promise)] = $canceller; 65 | 66 | return $promise; 67 | } 68 | 69 | /** 70 | * {@inheritdoc} 71 | */ 72 | public function createFulfilled($promiseOrValue = null) 73 | { 74 | return $this->getWebonyxPromiseAdapter()->createFulfilled($promiseOrValue); 75 | } 76 | 77 | /** 78 | * {@inheritdoc} 79 | */ 80 | public function createRejected($reason) 81 | { 82 | return $this->getWebonyxPromiseAdapter()->createRejected($reason); 83 | } 84 | 85 | /** 86 | * {@inheritdoc} 87 | */ 88 | public function createAll($promisesOrValues) 89 | { 90 | return $this->getWebonyxPromiseAdapter()->all($promisesOrValues); 91 | } 92 | 93 | /** 94 | * {@inheritdoc} 95 | */ 96 | public function isPromise($value, $strict = false) 97 | { 98 | if ($value instanceof Promise) { 99 | $value = $value->adoptedPromise; 100 | } 101 | $isStrictPromise = $value instanceof SyncPromise; 102 | if ($strict) { 103 | return $isStrictPromise; 104 | } 105 | 106 | return $isStrictPromise || is_callable([$value, 'then']); 107 | } 108 | 109 | /** 110 | * {@inheritdoc} 111 | */ 112 | public function await($promise = null, $unwrap = false) 113 | { 114 | if (null === $promise) { 115 | Deferred::runQueue(); 116 | SyncPromise::runQueue(); 117 | $this->cancellers = []; 118 | return null; 119 | } 120 | $promiseAdapter = $this->getWebonyxPromiseAdapter(); 121 | 122 | $resolvedValue = null; 123 | $exception = null; 124 | if (!$this->isPromise($promise)) { 125 | throw new \InvalidArgumentException(sprintf('The "%s" method must be called with a Promise ("then" method).', __METHOD__)); 126 | } 127 | 128 | try { 129 | $resolvedValue = $promiseAdapter->wait($promise); 130 | } catch (\Exception $reason) { 131 | $exception = $reason; 132 | } 133 | if ($exception instanceof \Exception) { 134 | if (!$unwrap) { 135 | return $exception; 136 | } 137 | throw $exception; 138 | } 139 | 140 | $hash = spl_object_hash($promise); 141 | unset($this->cancellers[$hash]); 142 | return $resolvedValue; 143 | } 144 | 145 | /** 146 | * {@inheritdoc} 147 | */ 148 | public function cancel($promise) 149 | { 150 | $hash = spl_object_hash($promise); 151 | if (!$this->isPromise($promise) || !isset($this->cancellers[$hash])) { 152 | throw new \InvalidArgumentException(sprintf('The "%s" method must be called with a compatible Promise.', __METHOD__)); 153 | } 154 | $canceller = $this->cancellers[$hash]; 155 | unset($this->cancellers[$hash]); 156 | $adoptedPromise = $promise; 157 | if ($promise instanceof Promise) { 158 | $adoptedPromise = $promise->adoptedPromise; 159 | } 160 | try { 161 | $canceller([$adoptedPromise, 'resolve'], [$adoptedPromise, 'reject']); 162 | } catch (\Exception $reason) { 163 | $adoptedPromise->reject($reason); 164 | } 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /lib/promise-adapter/src/PromiseAdapterInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Overblog\PromiseAdapter; 13 | 14 | /** 15 | * @template TPromise 16 | */ 17 | interface PromiseAdapterInterface 18 | { 19 | /** 20 | * Creates a Promise 21 | * 22 | * @param $resolve 23 | * @param $reject 24 | * @param callable $canceller 25 | * 26 | * @return TPromise a Promise 27 | */ 28 | public function create(&$resolve = null, &$reject = null, ?callable $canceller = null); 29 | 30 | /** 31 | * Creates a full filed Promise for a value if the value is not a promise. 32 | * 33 | * @param mixed $promiseOrValue 34 | * 35 | * @return TPromise a full filed Promise 36 | */ 37 | public function createFulfilled($promiseOrValue = null); 38 | 39 | /** 40 | * Creates a rejected promise for a reason if the reason is not a promise. If 41 | * the provided reason is a promise, then it is returned as-is. 42 | * 43 | * @param mixed $reason 44 | * 45 | * @return TPromise a rejected promise 46 | */ 47 | public function createRejected($reason); 48 | 49 | /** 50 | * Given an array of promises, return a promise that is fulfilled when all the 51 | * items in the array are fulfilled. 52 | * 53 | * @param mixed $promisesOrValues Promises or values. 54 | * 55 | * @return TPromise a Promise 56 | */ 57 | public function createAll($promisesOrValues); 58 | 59 | /** 60 | * Check if value is a promise 61 | * 62 | * @param mixed $value 63 | * @param bool $strict 64 | * 65 | * @return bool 66 | */ 67 | public function isPromise($value, $strict = false); 68 | 69 | /** 70 | * Cancel a promise 71 | * 72 | * @param TPromise $promise 73 | */ 74 | public function cancel($promise); 75 | 76 | /** 77 | * wait for Promise to complete 78 | * @param TPromise $promise 79 | * @param bool $unwrap 80 | * 81 | * @return mixed 82 | */ 83 | public function await($promise = null, $unwrap = false); 84 | } 85 | -------------------------------------------------------------------------------- /src/CacheMap.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Overblog\DataLoader; 13 | 14 | class CacheMap 15 | { 16 | private $promiseCache = []; 17 | 18 | public function get($key) 19 | { 20 | $key = self::serializedKey($key); 21 | 22 | return isset($this->promiseCache[$key]) ? $this->promiseCache[$key] : null; 23 | } 24 | 25 | public function has($key) 26 | { 27 | return isset($this->promiseCache[self::serializedKey($key)]); 28 | } 29 | 30 | public function set($key, $promise) 31 | { 32 | $this->promiseCache[self::serializedKey($key)] = $promise; 33 | 34 | return $this; 35 | } 36 | 37 | public function clear($key) 38 | { 39 | unset($this->promiseCache[self::serializedKey($key)]); 40 | 41 | return $this; 42 | } 43 | 44 | public function clearAll() 45 | { 46 | $this->promiseCache = []; 47 | 48 | return $this; 49 | } 50 | 51 | private static function serializedKey($key) 52 | { 53 | if (is_object($key)) { 54 | return spl_object_hash($key); 55 | } elseif (is_array($key)) { 56 | return json_encode($key); 57 | } 58 | 59 | return $key; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/DataLoader.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Overblog\DataLoader; 13 | 14 | use Overblog\PromiseAdapter\PromiseAdapterInterface; 15 | 16 | class DataLoader implements DataLoaderInterface 17 | { 18 | /** 19 | * @var callable 20 | */ 21 | private $batchLoadFn; 22 | 23 | /** 24 | * @var Option 25 | */ 26 | private $options; 27 | 28 | /** 29 | * @var CacheMap 30 | */ 31 | private $promiseCache; 32 | 33 | /** 34 | * @var array 35 | */ 36 | private $queue = []; 37 | 38 | /** 39 | * @var self[] 40 | */ 41 | private static $instances = []; 42 | 43 | /** 44 | * @var PromiseAdapterInterface 45 | */ 46 | private $promiseAdapter; 47 | 48 | public function __construct(callable $batchLoadFn, PromiseAdapterInterface $promiseFactory, ?Option $options = null) 49 | { 50 | $this->batchLoadFn = $batchLoadFn; 51 | $this->promiseAdapter = $promiseFactory; 52 | $this->options = $options ?: new Option(); 53 | $this->promiseCache = $this->options->getCacheMap(); 54 | self::$instances[] = $this; 55 | } 56 | 57 | /** 58 | * {@inheritdoc} 59 | */ 60 | public function load($key) 61 | { 62 | $this->checkKey($key, __METHOD__); 63 | // Determine options 64 | $shouldBatch = $this->options->shouldBatch(); 65 | $shouldCache = $this->options->shouldCache(); 66 | $cacheKey = $this->getCacheKeyFromKey($key); 67 | 68 | // If caching and there is a cache-hit, return cached Promise. 69 | if ($shouldCache) { 70 | $cachedPromise = $this->promiseCache->get($cacheKey); 71 | if ($cachedPromise) { 72 | return $cachedPromise; 73 | } 74 | } 75 | 76 | // Otherwise, produce a new Promise for this value. 77 | $promise = $this->getPromiseAdapter()->create( 78 | $resolve, 79 | $reject, 80 | function () { 81 | // Cancel/abort any running operations like network connections, streams etc. 82 | 83 | throw new \RuntimeException('DataLoader destroyed before promise complete.'); 84 | } 85 | ); 86 | 87 | $this->queue[] = [ 88 | 'key' => $key, 89 | 'resolve' => $resolve, 90 | 'reject' => $reject, 91 | 'promise' => $promise, 92 | ]; 93 | 94 | // Determine if a dispatch of this queue should be scheduled. 95 | // A single dispatch should be scheduled per queue at the time when the 96 | // queue changes from "empty" to "full". 97 | if (count($this->queue) === 1) { 98 | if (!$shouldBatch) { 99 | // Otherwise dispatch the (queue of one) immediately. 100 | $this->dispatchQueue(); 101 | } 102 | } 103 | // If caching, cache this promise. 104 | if ($shouldCache) { 105 | $this->promiseCache->set($cacheKey, $promise); 106 | } 107 | 108 | return $promise; 109 | } 110 | 111 | /** 112 | * {@inheritdoc} 113 | */ 114 | public function loadMany($keys) 115 | { 116 | if (!is_array($keys) && !$keys instanceof \Traversable) { 117 | throw new \InvalidArgumentException(sprintf('The "%s" method must be called with Array but got: %s.', __METHOD__, gettype($keys))); 118 | } 119 | return $this->getPromiseAdapter()->createAll(array_map( 120 | function ($key) { 121 | return $this->load($key); 122 | }, 123 | $keys 124 | )); 125 | } 126 | 127 | /** 128 | * {@inheritdoc} 129 | */ 130 | public function clear($key) 131 | { 132 | $this->checkKey($key, __METHOD__); 133 | $cacheKey = $this->getCacheKeyFromKey($key); 134 | $this->promiseCache->clear($cacheKey); 135 | 136 | return $this; 137 | } 138 | 139 | /** 140 | * {@inheritdoc} 141 | */ 142 | public function clearAll() 143 | { 144 | $this->promiseCache->clearAll(); 145 | 146 | return $this; 147 | } 148 | 149 | /** 150 | * {@inheritdoc} 151 | */ 152 | public function prime($key, $value) 153 | { 154 | $this->checkKey($key, __METHOD__); 155 | 156 | $cacheKey = $this->getCacheKeyFromKey($key); 157 | 158 | // Only add the key if it does not already exist. 159 | if (!$this->promiseCache->has($cacheKey)) { 160 | // Cache a rejected promise if the value is an Error, in order to match 161 | // the behavior of load(key). 162 | $promise = $value instanceof \Exception ? $this->getPromiseAdapter()->createRejected($value) : $this->getPromiseAdapter()->createFulfilled($value); 163 | 164 | $this->promiseCache->set($cacheKey, $promise); 165 | } 166 | 167 | return $this; 168 | } 169 | 170 | public function __destruct() 171 | { 172 | if ($this->needProcess()) { 173 | foreach ($this->queue as $data) { 174 | try { 175 | $this->getPromiseAdapter()->cancel($data['promise']); 176 | } catch (\Exception $e) { 177 | // no need to do nothing if cancel failed 178 | } 179 | } 180 | $this->await(); 181 | } 182 | foreach (self::$instances as $i => $instance) { 183 | if ($this !== $instance) { 184 | continue; 185 | } 186 | unset(self::$instances[$i]); 187 | } 188 | } 189 | 190 | protected function needProcess() 191 | { 192 | return count($this->queue) > 0; 193 | } 194 | 195 | protected function process() 196 | { 197 | if ($this->needProcess()) { 198 | $this->getPromiseAdapter()->await(); 199 | $this->dispatchQueue(); 200 | } 201 | } 202 | 203 | protected function getPromiseAdapter() 204 | { 205 | return $this->promiseAdapter; 206 | } 207 | 208 | /** 209 | * {@inheritdoc} 210 | */ 211 | public static function await($promise = null, $unwrap = true) 212 | { 213 | self::awaitInstances(); 214 | 215 | if (null === $promise) { 216 | return null; 217 | } 218 | 219 | if (is_callable([$promise, 'then'])) { 220 | $isPromiseCompleted = false; 221 | $resolvedValue = null; 222 | $rejectedReason = null; 223 | 224 | $promise->then( 225 | function ($value) use (&$isPromiseCompleted, &$resolvedValue) { 226 | $isPromiseCompleted = true; 227 | $resolvedValue = $value; 228 | }, 229 | function ($reason) use (&$isPromiseCompleted, &$rejectedReason) { 230 | $isPromiseCompleted = true; 231 | $rejectedReason = $reason; 232 | } 233 | ); 234 | 235 | //Promise is completed? 236 | if ($isPromiseCompleted) { 237 | // rejected ? 238 | if ($rejectedReason instanceof \Exception || (interface_exists('\Throwable') && $rejectedReason instanceof \Throwable)) { 239 | if (!$unwrap) { 240 | return $rejectedReason; 241 | } 242 | throw $rejectedReason; 243 | } 244 | 245 | return $resolvedValue; 246 | } 247 | } 248 | 249 | if (empty(self::$instances)) { 250 | throw new \RuntimeException('Found no active DataLoader instance.'); 251 | } 252 | 253 | return self::$instances[0]->getPromiseAdapter()->await($promise, $unwrap); 254 | } 255 | 256 | private static function awaitInstances() 257 | { 258 | do { 259 | $wait = false; 260 | $dataLoaders = self::$instances; 261 | 262 | foreach ($dataLoaders as $dataLoader) { 263 | if (!$dataLoader || !$dataLoader->needProcess()) { 264 | $wait |= false; 265 | continue; 266 | } 267 | $wait = true; 268 | $dataLoader->process(); 269 | } 270 | } while ($wait); 271 | } 272 | 273 | /** 274 | * @param $key 275 | * 276 | * @return mixed 277 | */ 278 | protected function getCacheKeyFromKey($key) 279 | { 280 | $cacheKeyFn = $this->options->getCacheKeyFn(); 281 | $cacheKey = $cacheKeyFn ? $cacheKeyFn($key) : $key; 282 | 283 | return $cacheKey; 284 | } 285 | 286 | /** 287 | * @param $key 288 | * @param $method 289 | */ 290 | protected function checkKey($key, $method) 291 | { 292 | if (null === $key) { 293 | throw new \InvalidArgumentException( 294 | sprintf('The "%s" method must be called with a value, but got: %s.', $method, gettype($key)) 295 | ); 296 | } 297 | } 298 | 299 | /** 300 | * Given the current state of a Loader instance, perform a batch load 301 | * from its current queue. 302 | */ 303 | private function dispatchQueue() 304 | { 305 | // Take the current loader queue, replacing it with an empty queue. 306 | $queue = $this->queue; 307 | $this->queue = []; 308 | $queueLength = count($queue); 309 | // If a maxBatchSize was provided and the queue is longer, then segment the 310 | // queue into multiple batches, otherwise treat the queue as a single batch. 311 | $maxBatchSize = $this->options->getMaxBatchSize(); 312 | if ($maxBatchSize && $maxBatchSize > 0 && $maxBatchSize < $queueLength) { 313 | for ($i = 0; $i < $queueLength / $maxBatchSize; $i++) { 314 | $offset = $i * $maxBatchSize; 315 | $length = ($i + 1) * $maxBatchSize - $offset; 316 | 317 | $this->dispatchQueueBatch(array_slice($queue, $offset, $length)); 318 | } 319 | } else { 320 | $this->dispatchQueueBatch($queue); 321 | } 322 | } 323 | 324 | private function dispatchQueueBatch(array $queue) 325 | { 326 | // Collect all keys to be loaded in this dispatch 327 | $keys = array_column($queue, 'key'); 328 | 329 | // Call the provided batchLoadFn for this loader with the loader queue's keys. 330 | $batchLoadFn = $this->batchLoadFn; 331 | $batchPromise = $batchLoadFn($keys); 332 | 333 | // Assert the expected response from batchLoadFn 334 | if (!$batchPromise || !is_callable([$batchPromise, 'then'])) { 335 | $this->failedDispatch($queue, new \RuntimeException( 336 | 'DataLoader must be constructed with a function which accepts ' . 337 | 'Array and returns Promise>, but the function did ' . 338 | sprintf('not return a Promise: %s.', gettype($batchPromise)) 339 | )); 340 | 341 | return; 342 | } 343 | 344 | // Await the resolution of the call to batchLoadFn. 345 | $batchPromise->then( 346 | function ($values) use ($keys, $queue) { 347 | // Assert the expected resolution from batchLoadFn. 348 | if (!is_array($values) && !$values instanceof \Traversable) { 349 | throw new \RuntimeException( 350 | 'DataLoader must be constructed with a function which accepts ' . 351 | 'Array and returns Promise>, but the function did ' . 352 | sprintf('not return a Promise of an Array: %s.', gettype($values)) 353 | ); 354 | } 355 | if (count($values) !== count($keys)) { 356 | throw new \RuntimeException( 357 | 'DataLoader must be constructed with a function which accepts ' . 358 | 'Array and returns Promise>, but the function did ' . 359 | 'not return a Promise of an Array of the same length as the Array of keys.' 360 | ); 361 | } 362 | 363 | // Step through the values, resolving or rejecting each Promise in the 364 | // loaded queue. 365 | foreach ($queue as $index => $data) { 366 | $value = $values[$index]; 367 | if ($value instanceof \Exception) { 368 | $data['reject']($value); 369 | } else { 370 | $data['resolve']($value); 371 | } 372 | }; 373 | } 374 | )->then(null, function ($error) use ($queue) { 375 | $this->failedDispatch($queue, $error); 376 | }); 377 | } 378 | 379 | /** 380 | * Do not cache individual loads if the entire batch dispatch fails, 381 | * but still reject each request so they do not hang. 382 | * @param array $queue 383 | * @param \Exception $error 384 | */ 385 | private function failedDispatch($queue, \Exception $error) 386 | { 387 | foreach ($queue as $index => $data) { 388 | $this->clear($data['key']); 389 | $data['reject']($error); 390 | } 391 | } 392 | } 393 | -------------------------------------------------------------------------------- /src/DataLoaderInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Overblog\DataLoader; 13 | 14 | interface DataLoaderInterface 15 | { 16 | /** 17 | * Loads a key, returning a `Promise` for the value represented by that key. 18 | * 19 | * @param mixed $key 20 | * 21 | * @return mixed return a Promise 22 | */ 23 | public function load($key); 24 | 25 | /** 26 | * Loads multiple keys, promising an array of values: 27 | * 28 | * list($a, $b) = $myLoader->loadMany(['a', 'b']); 29 | * 30 | * This is equivalent to the more verbose: 31 | * 32 | * list($a, $b) = \React\Promise\all([ 33 | * $myLoader->load('a'), 34 | * $myLoader->load('b') 35 | * ]); 36 | * @param array $keys 37 | * 38 | * @return mixed return a Promise 39 | */ 40 | public function loadMany($keys); 41 | 42 | /** 43 | * Clears the value at `key` from the cache, if it exists. 44 | * 45 | * @param $key 46 | * @return $this 47 | */ 48 | public function clear($key); 49 | 50 | /** 51 | * Clears the entire cache. To be used when some event results in unknown 52 | * invalidations across this particular `DataLoader`. 53 | * 54 | * @return $this 55 | */ 56 | public function clearAll(); 57 | 58 | /** 59 | * Adds the provided key and value to the cache. If the key already exists, no 60 | * change is made. Returns itself for method chaining. 61 | * @param $key 62 | * @param $value 63 | * @return $this 64 | */ 65 | public function prime($key, $value); 66 | 67 | /** 68 | * @param $promise 69 | * @param bool $unwrap controls whether or not the value of the promise is returned for a fulfilled promise or if an exception is thrown if the promise is rejected 70 | * @return mixed 71 | * @throws \Exception 72 | */ 73 | public static function await($promise = null, $unwrap = true); 74 | } 75 | -------------------------------------------------------------------------------- /src/Option.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Overblog\DataLoader; 13 | 14 | class Option 15 | { 16 | /** 17 | * @var bool 18 | */ 19 | private $batch; 20 | 21 | /** 22 | * @var |null 23 | */ 24 | private $maxBatchSize; 25 | 26 | /** 27 | * @var bool 28 | */ 29 | private $cache; 30 | 31 | /** 32 | * @var callable 33 | */ 34 | private $cacheKeyFn; 35 | 36 | /** 37 | * @var CacheMap 38 | */ 39 | private $cacheMap; 40 | 41 | public function __construct(array $params = []) 42 | { 43 | $defaultOptions = [ 44 | 'batch' => true, 45 | 'maxBatchSize' => null, 46 | 'cache' => true, 47 | 'cacheKeyFn' => null, 48 | 'cacheMap' => new CacheMap() 49 | ]; 50 | 51 | $options = array_merge($defaultOptions, $params); 52 | 53 | foreach ($options as $property => $value) { 54 | $this->$property = $value; 55 | } 56 | } 57 | 58 | /** 59 | * @return boolean 60 | */ 61 | public function shouldBatch() 62 | { 63 | return $this->batch; 64 | } 65 | 66 | /** 67 | * @return int 68 | */ 69 | public function getMaxBatchSize() 70 | { 71 | return $this->maxBatchSize; 72 | } 73 | 74 | /** 75 | * @return boolean 76 | */ 77 | public function shouldCache() 78 | { 79 | return $this->cache; 80 | } 81 | 82 | /** 83 | * @return callable 84 | */ 85 | public function getCacheKeyFn() 86 | { 87 | return $this->cacheKeyFn; 88 | } 89 | 90 | /** 91 | * @return CacheMap 92 | */ 93 | public function getCacheMap() 94 | { 95 | return $this->cacheMap; 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/Promise/Adapter/Webonyx/GraphQL/SyncPromiseAdapter.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Overblog\DataLoader\Promise\Adapter\Webonyx\GraphQL; 13 | 14 | use GraphQL\Executor\Promise\Adapter\SyncPromiseAdapter as BaseSyncPromiseAdapter; 15 | use GraphQL\Executor\Promise\Promise; 16 | use Overblog\DataLoader\DataLoader; 17 | 18 | class SyncPromiseAdapter extends BaseSyncPromiseAdapter 19 | { 20 | protected function beforeWait(Promise $promise): void 21 | { 22 | DataLoader::await(); 23 | } 24 | 25 | protected function onWait(Promise $promise): void 26 | { 27 | DataLoader::await(); 28 | } 29 | } 30 | --------------------------------------------------------------------------------