diff --git a/src/Executor/ExecutionContext.php b/src/Executor/ExecutionContext.php index 8acf3db..6664eee 100644 --- a/src/Executor/ExecutionContext.php +++ b/src/Executor/ExecutionContext.php @@ -43,6 +43,11 @@ class ExecutionContext */ public $errors; + /** + * @var array + */ + public $memoized = []; + public function __construct($schema, $fragments, $root, $operation, $variables, $errors) { $this->schema = $schema; diff --git a/src/Executor/Executor.php b/src/Executor/Executor.php index 0f336a7..7dea869 100644 --- a/src/Executor/Executor.php +++ b/src/Executor/Executor.php @@ -359,58 +359,95 @@ class Executor private static function resolveField(ExecutionContext $exeContext, ObjectType $parentType, $source, $fieldASTs) { $fieldAST = $fieldASTs[0]; - $fieldName = $fieldAST->name->value; - $fieldDef = self::getFieldDef($exeContext->schema, $parentType, $fieldName); + $uid = self::getFieldUid($fieldAST); - if (!$fieldDef) { - return self::$UNDEFINED; + // Get memoized variables if they exist + if (isset($exeContext->memoized['resolveField'][$uid])) { + $memoized = $exeContext->memoized['resolveField'][$uid]; + $fieldDef = $memoized['fieldDef']; + $returnType = $fieldDef->getType(); + $args = $memoized['args']; + $info = $memoized['info']; + } + else { + $fieldName = $fieldAST->name->value; + + $fieldDef = self::getFieldDef($exeContext->schema, $parentType, $fieldName); + + if (!$fieldDef) { + return self::$UNDEFINED; + } + + $returnType = $fieldDef->getType(); + + // Build hash of arguments from the field.arguments AST, using the + // variables scope to fulfill any variable references. + // TODO: find a way to memoize, in case this field is within a List type. + $args = Values::getArgumentValues( + $fieldDef->args, + $fieldAST->arguments, + $exeContext->variableValues + ); + + // The resolve function's optional third argument is a collection of + // information about the current execution state. + $info = new ResolveInfo([ + 'fieldName' => $fieldName, + 'fieldASTs' => $fieldASTs, + 'returnType' => $returnType, + 'parentType' => $parentType, + 'schema' => $exeContext->schema, + 'fragments' => $exeContext->fragments, + 'rootValue' => $exeContext->rootValue, + 'operation' => $exeContext->operation, + 'variableValues' => $exeContext->variableValues, + ]); + + // Memoizing results for same query field + // (useful for lists when several values are resolved against the same field) + if ($returnType instanceof ObjectType) { + $memoized = $exeContext->memoized['resolveField'][$uid] = [ + 'fieldDef' => $fieldDef, + 'args' => $args, + 'info' => $info, + 'results' => new \SplObjectStorage + ]; + } } - $returnType = $fieldDef->getType(); + // When source value is object it is possible to memoize certain subset of results + $isObject = is_object($source); - if (isset($fieldDef->resolveFn)) { - $resolveFn = $fieldDef->resolveFn; - } else if (isset($parentType->resolveFieldFn)) { - $resolveFn = $parentType->resolveFieldFn; + if ($isObject && isset($memoized['results'][$source])) { + $result = $exeContext->memoized['resolveField'][$uid]['results'][$source]; } else { - $resolveFn = self::$defaultResolveFn; + if (isset($fieldDef->resolveFn)) { + $resolveFn = $fieldDef->resolveFn; + } else if (isset($parentType->resolveFieldFn)) { + $resolveFn = $parentType->resolveFieldFn; + } else { + $resolveFn = self::$defaultResolveFn; + } + + // Get the resolve function, regardless of if its result is normal + // or abrupt (error). + $result = self::resolveOrError($resolveFn, $source, $args, $info); + + $result = self::completeValueCatchingError( + $exeContext, + $returnType, + $fieldASTs, + $info, + $result + ); + + if ($isObject && isset($memoized['results'])) { + $exeContext->memoized['resolveField'][$uid]['results'][$source] = $result; + } } - // Build hash of arguments from the field.arguments AST, using the - // variables scope to fulfill any variable references. - // TODO: find a way to memoize, in case this field is within a List type. - $args = Values::getArgumentValues( - $fieldDef->args, - $fieldAST->arguments, - $exeContext->variableValues - ); - - // The resolve function's optional third argument is a collection of - // information about the current execution state. - $info = new ResolveInfo([ - 'fieldName' => $fieldName, - 'fieldASTs' => $fieldASTs, - 'returnType' => $returnType, - 'parentType' => $parentType, - 'schema' => $exeContext->schema, - 'fragments' => $exeContext->fragments, - 'rootValue' => $exeContext->rootValue, - 'operation' => $exeContext->operation, - 'variableValues' => $exeContext->variableValues, - ]); - - // Get the resolve function, regardless of if its result is normal - // or abrupt (error). - $result = self::resolveOrError($resolveFn, $source, $args, $info); - - return self::completeValueCatchingError( - $exeContext, - $returnType, - $fieldASTs, - $info, - $result - ); + return $result; } // Isolates the "ReturnOrAbrupt" behavior to not de-opt the `resolveField` @@ -554,15 +591,23 @@ class Executor $subFieldASTs = new \ArrayObject(); $visitedFragmentNames = new \ArrayObject(); for ($i = 0; $i < count($fieldASTs); $i++) { - $selectionSet = $fieldASTs[$i]->selectionSet; - if ($selectionSet) { - $subFieldASTs = self::collectFields( - $exeContext, - $runtimeType, - $selectionSet, - $subFieldASTs, - $visitedFragmentNames - ); + // Get memoized value if it exists + $uid = self::getFieldUid($fieldASTs[$i]); + if (isset($exeContext->memoized['collectSubFields'][$uid][$runtimeType->name])) { + $subFieldASTs = $exeContext->memoized['collectSubFields'][$uid][$runtimeType->name]; + } + else { + $selectionSet = $fieldASTs[$i]->selectionSet; + if ($selectionSet) { + $subFieldASTs = self::collectFields( + $exeContext, + $runtimeType, + $selectionSet, + $subFieldASTs, + $visitedFragmentNames + ); + $exeContext->memoized['collectSubFields'][$uid][$runtimeType->name] = $subFieldASTs; + } } } @@ -622,4 +667,15 @@ class Executor $tmp = $parentType->getFields(); return isset($tmp[$fieldName]) ? $tmp[$fieldName] : null; } + + /** + * Get an unique identifier for a FieldAST. + * + * @param object $fieldAST + * @return string + */ + private static function getFieldUid($fieldAST) + { + return $fieldAST->loc->start . '-' . $fieldAST->loc->end; + } } diff --git a/tests/Executor/ExecutorTest.php b/tests/Executor/ExecutorTest.php index 9c23386..0ca0b35 100644 --- a/tests/Executor/ExecutorTest.php +++ b/tests/Executor/ExecutorTest.php @@ -546,4 +546,99 @@ class ExecutorTest extends \PHPUnit_Framework_TestCase $this->assertEquals($expected, $result->toArray()); } + + public function testResolvedValueIsMemoized() + { + $doc = ' + query Q { + a { + b { + c + d + } + } + } + '; + + $memoizedValue = new \ArrayObject([ + 'b' => 'id1' + ]); + + $A = null; + + $Test = new ObjectType([ + 'name' => 'Test', + 'fields' => [ + 'a' => [ + 'type' => function() use (&$A) {return Type::listOf($A);}, + 'resolve' => function() use ($memoizedValue) { + return [ + $memoizedValue, + new \ArrayObject([ + 'b' => 'id2', + ]), + $memoizedValue, + new \ArrayObject([ + 'b' => 'id2', + ]) + ]; + } + ] + ] + ]); + + $callCounts = ['id1' => 0, 'id2' => 0]; + + $A = new ObjectType([ + 'name' => 'A', + 'fields' => [ + 'b' => [ + 'type' => new ObjectType([ + 'name' => 'B', + 'fields' => [ + 'c' => ['type' => Type::string()], + 'd' => ['type' => Type::string()] + ] + ]), + 'resolve' => function($value) use (&$callCounts) { + $callCounts[$value['b']]++; + + switch ($value['b']) { + case 'id1': + return [ + 'c' => 'c1', + 'd' => 'd1' + ]; + case 'id2': + return [ + 'c' => 'c2', + 'd' => 'd2' + ]; + } + } + ] + ] + ]); + + // Test that value resolved once is memoized for same query field + $schema = new Schema($Test); + + $query = Parser::parse($doc); + $result = Executor::execute($schema, $query); + $expected = [ + 'data' => [ + 'a' => [ + ['b' => ['c' => 'c1', 'd' => 'd1']], + ['b' => ['c' => 'c2', 'd' => 'd2']], + ['b' => ['c' => 'c1', 'd' => 'd1']], + ['b' => ['c' => 'c2', 'd' => 'd2']], + ] + ] + ]; + + $this->assertEquals($expected, $result->toArray()); + + $this->assertSame($callCounts['id1'], 1); // Result for id1 is expected to be memoized after first call + $this->assertSame($callCounts['id2'], 2); + } }