From 118d10ddc00fd627d22afb24cb214ed5fb3a4636 Mon Sep 17 00:00:00 2001 From: drtheuns Date: Sat, 29 Feb 2020 18:14:22 +0100 Subject: [PATCH] Separate the associations from the fields in the QueryBuilder (#21) * Separates the associations from the fields * Fix remove trailing comma for PHP7.2 compat --- resources/lang/en/validation.php | 10 +- .../Exceptions/DefinitionException.php | 18 ++ .../Exceptions/InvalidOutputException.php | 15 ++ src/Apitizer/Interpreter/QueryInterpreter.php | 92 ++++----- src/Apitizer/JsonApi/Resource.php | 20 ++ src/Apitizer/Parser/Context.php | 24 ++- src/Apitizer/Parser/InputParser.php | 81 ++++---- src/Apitizer/Parser/ParsedInput.php | 18 +- src/Apitizer/Parser/Relation.php | 23 ++- src/Apitizer/QueryBuilder.php | 169 ++++------------- src/Apitizer/Rendering/AbstractRenderer.php | 28 +++ src/Apitizer/Rendering/BasicRenderer.php | 141 ++++++++++---- src/Apitizer/Rendering/JsonApiRenderer.php | 86 +++++++++ src/Apitizer/Rendering/Renderer.php | 7 +- src/Apitizer/Support/DefinitionHelper.php | 58 ++++-- src/Apitizer/Support/FetchSpecFactory.php | 178 ++++++++++++++++++ src/Apitizer/Support/SchemaValidator.php | 14 ++ src/Apitizer/Types/AbstractField.php | 4 +- src/Apitizer/Types/Apidoc.php | 24 +-- src/Apitizer/Types/Association.php | 56 +++--- src/Apitizer/Types/Concerns/HasPolicy.php | 5 +- src/Apitizer/Types/EnumField.php | 2 +- src/Apitizer/Types/FetchSpec.php | 36 +++- .../Commands/ValidateSchemaCommandTest.php | 2 +- tests/Feature/QueryBuilder/PolicyTest.php | 33 ++-- tests/Feature/QueryBuilder/SelectTest.php | 9 +- tests/RequestBuilder.php | 2 +- tests/Support/Builders/CommentBuilder.php | 6 + tests/Support/Builders/EmptyBuilder.php | 5 + tests/Support/Builders/PostBuilder.php | 6 + tests/Support/Builders/UserBuilder.php | 6 + tests/Unit/Parser/FieldParsingTest.php | 58 +++--- tests/Unit/Parser/FilterParserTest.php | 5 +- tests/Unit/Parser/InputParserTest.php | 3 +- tests/Unit/Parser/SortParsingTest.php | 5 +- tests/Unit/Types/FetchSpecTest.php | 19 +- 36 files changed, 868 insertions(+), 400 deletions(-) create mode 100644 src/Apitizer/JsonApi/Resource.php create mode 100644 src/Apitizer/Rendering/AbstractRenderer.php create mode 100644 src/Apitizer/Rendering/JsonApiRenderer.php create mode 100644 src/Apitizer/Support/FetchSpecFactory.php diff --git a/resources/lang/en/validation.php b/resources/lang/en/validation.php index 45d71bf..317da2a 100644 --- a/resources/lang/en/validation.php +++ b/resources/lang/en/validation.php @@ -36,17 +36,17 @@ 'min' => 'must be greater than or equal to :min', 'mimetypes' => 'The file must have one of mimetypes', 'mimes' => 'The file must have one of mimes', - 'not_in' => 'must not be included in: :values', + 'not_in' => 'must not be one of', 'not_regex' => 'must not match the regex: :regex', 'nullable' => 'may be nullable', 'present' => 'The field must be present', 'regex' => 'must match the regex: :regex', 'required_unless' => 'is required unless :field has value: :value', 'required_if' => 'The field is required if: :reason', - 'required_with' => 'The field is required only if :fields are present', - 'required_with_all' => 'The field is required only if all of :fields are present', - 'required_without' => 'The field is required only when :fields are not present', - 'required_without_all' => 'The field is required only when :fields are not present', + 'required_with' => 'The field is required only if any of the fields is present', + 'required_with_all' => 'The field is required only if all of the fields are present', + 'required_without' => 'The field is required only when any fields are not present', + 'required_without_all' => 'The field is required only when all fields are not present', 'same' => 'must be the same as the value of field :field', 'size' => 'must have a matching size with :size', 'starts_with' => 'must start with one of', diff --git a/src/Apitizer/Exceptions/DefinitionException.php b/src/Apitizer/Exceptions/DefinitionException.php index 39871fd..630e672 100644 --- a/src/Apitizer/Exceptions/DefinitionException.php +++ b/src/Apitizer/Exceptions/DefinitionException.php @@ -84,6 +84,24 @@ static function fieldDefinitionExpected(QueryBuilder $queryBuilder, string $name return new static($message, $queryBuilder, 'field', $name); } + /** + * @param QueryBuilder $queryBuilder + * @param string $name + * @param mixed $given + */ + static function associationDefinitionExpected( + QueryBuilder $queryBuilder, + string $name, + $given + ): self { + $class = get_class($queryBuilder); + $type = is_object($given) ? get_class($given) : gettype($given); + $message = "Expected [$name] on [$class] to be an \Apitizer\Types\Association, " + . "but got a [$type]"; + + return new static($message, $queryBuilder, 'association', $name); + } + /** * @param QueryBuilder $queryBuilder * @param string $name diff --git a/src/Apitizer/Exceptions/InvalidOutputException.php b/src/Apitizer/Exceptions/InvalidOutputException.php index d04eb40..7771b14 100644 --- a/src/Apitizer/Exceptions/InvalidOutputException.php +++ b/src/Apitizer/Exceptions/InvalidOutputException.php @@ -93,6 +93,21 @@ public static function castError(AbstractField $field, CastException $e, $row): return $e; } + /** + * @param QueryBuilder $queryBuilder + * @param mixed $row + */ + public static function noJsonApiIdentifier(QueryBuilder $queryBuilder, $row): self + { + $class = get_class($queryBuilder); + $obj = is_array($row) ? json_encode($row) : gettype($row); + $message = "Failed to get a JSON-API id reference for [$class] from [$obj]"; + + $e = new static($message); + $e->queryBuilder = $queryBuilder; + return $e; + } + /** * Do a best attempt at getting a reference to the object that caused an * exception. diff --git a/src/Apitizer/Interpreter/QueryInterpreter.php b/src/Apitizer/Interpreter/QueryInterpreter.php index 4b4b15e..f18d141 100644 --- a/src/Apitizer/Interpreter/QueryInterpreter.php +++ b/src/Apitizer/Interpreter/QueryInterpreter.php @@ -28,7 +28,10 @@ public function build(QueryBuilder $queryBuilder, FetchSpec $fetchSpec): Builder $query = $queryBuilder->beforeQuery($query, $fetchSpec); $this->applySelect( - $query, $fetchSpec->getFields(), $queryBuilder->getAlwaysLoadColumns() + $query, + $fetchSpec->getFields(), + $fetchSpec->getAssociations(), + $queryBuilder->getAlwaysLoadColumns() ); $this->applySorting($query, $fetchSpec->getSorts()); $this->applyFilters($query, $fetchSpec->getFilters()); @@ -40,11 +43,16 @@ public function build(QueryBuilder $queryBuilder, FetchSpec $fetchSpec): Builder /** * @param Builder $query - * @param (AbstractField|Association)[] $fields + * @param AbstractField[] $fields + * @param Association[] $associations * @param string[] $additionalSelects */ - private function applySelect(Builder $query, array $fields, array $additionalSelects = []): void - { + private function applySelect( + Builder $query, + array $fields, + array $associations, + array $additionalSelects = [] + ): void { /** @var \Illuminate\Database\Eloquent\Model $model */ $model = $query->getModel(); @@ -52,46 +60,46 @@ private function applySelect(Builder $query, array $fields, array $additionalSel // depend on it. $selectKeys = array_merge([$model->getKeyName()], $additionalSelects); - foreach ($fields as $fieldOrAssoc) { - if ($fieldOrAssoc instanceof Field) { - // Also load any of the selected keys. - $selectKeys[] = $fieldOrAssoc->getKey(); - } else if ($fieldOrAssoc instanceof Association) { - // We also need to ensure that we always load the right foreign - // keys, otherwise we won't be able load relationships. - $relationship = $model->{$fieldOrAssoc->getKey()}(); - - // Perhaps we could even eager load belongsTo relationships - // in-line using a join and table aliases, since there's always - // only one related row in a belongsTo. - if ($relationship instanceof BelongsTo) { - $selectKeys[] = $relationship->getForeignKeyName(); - } + foreach ($fields as $field) { + // Generated fields don't select anything. + if ($field instanceof Field) { + $selectKeys[] = $field->getKey(); + } + } - // Finally, we'll recursively eager load relationships with - // efficient selects on those models as well. - $query->with([ - $fieldOrAssoc->getKey() => function ($relation) use ($fieldOrAssoc) { - // Similar to the BelongsTo above, we need to select the - // foreign key on the related model, otherwise Eloquent - // won't be able to piece things back together again. - $additionalSelects = $relation instanceof HasOneOrMany - ? [$relation->getForeignKeyName()] - : []; - - $additionalSelects = array_merge( - $additionalSelects, - $fieldOrAssoc->getRelatedQueryBuilder()->getAlwaysLoadColumns() - ); - - $this->applySelect( - $relation->getQuery(), - $fieldOrAssoc->getFields() ?? [], - $additionalSelects - ); - }] - ); + foreach ($associations as $association) { + $relationship = $model->{$association->getKey()}(); + + // Ensure that we always load the correct foreign key, otherwise + // Eloquent won't be able to load relationships. + if ($relationship instanceof BelongsTo) { + $selectKeys[] = $relationship->getForeignKeyName(); } + + // Finally, recursively eager load relationships with efficient + // selects on those queries as well. + $query->with([ + $association->getKey() => function ($relation) use ($association) { + // Similar to the BelongsTo above, we need to select the + // foreign key on the related model, otherwise Eloquent + // won't be able to piece things back together again. + $additionalSelects = $relation instanceof HasOneOrMany + ? [$relation->getForeignKeyName()] + : []; + + $additionalSelects = array_merge( + $additionalSelects, + $association->getRelatedQueryBuilder()->getAlwaysLoadColumns() + ); + + $this->applySelect( + $relation->getQuery(), + $association->getFields() ?? [], + $association->getAssociations() ?? [], + $additionalSelects + ); + } + ]); } $query->select(array_unique($selectKeys)); diff --git a/src/Apitizer/JsonApi/Resource.php b/src/Apitizer/JsonApi/Resource.php new file mode 100644 index 0000000..5306c75 --- /dev/null +++ b/src/Apitizer/JsonApi/Resource.php @@ -0,0 +1,20 @@ +stack = $stack; $this->parent = $parent; } - public function makeChildContext(): Context + public function makeChildContext(Relation $relation): Context + { + return new self($relation, $this); + } + + public function addField(string $field): void + { + $this->stack->addField($field); + } + + public function addRelation(Relation $relation): void { - return new self($this); + $this->stack->addRelation($relation); } } diff --git a/src/Apitizer/Parser/InputParser.php b/src/Apitizer/Parser/InputParser.php index c0a7179..31838c3 100644 --- a/src/Apitizer/Parser/InputParser.php +++ b/src/Apitizer/Parser/InputParser.php @@ -17,37 +17,43 @@ class InputParser implements Parser public function parse(RawInput $rawInput): ParsedInput { $parsedInput = new ParsedInput(); - $parsedInput->fields = $this->parseFields($rawInput->getFields()); - $parsedInput->filters = $this->parseFilters($rawInput->getFilters()); - $parsedInput->sorts = $this->parseSorts($rawInput->getSorts()); + + $this->parseFields($parsedInput, $rawInput->getFields()); + $this->parseFilters($parsedInput, $rawInput->getFilters()); + $this->parseSorts($parsedInput, $rawInput->getSorts()); + return $parsedInput; } /** + * @param ParsedInput $parsedInput * @param string|string[]|mixed $rawFields - * @return (string|Relation)[] */ - public function parseFields($rawFields): array + public function parseFields(ParsedInput $parsedInput, $rawFields): void { // Input examples: // id,name // id,"first,name",comments(id,"wo)(,-w") if (empty($rawFields)) { - return []; + $parsedInput->fields = []; + return; } if (\is_array($rawFields)) { - return $rawFields; + $parsedInput->fields = $rawFields; + return; } if (! is_string($rawFields)) { - return []; + $parsedInput->fields = []; + return; } - $context = new Context(); + $context = new Context($parsedInput); if (! $characters = $this->stringToArray($rawFields)) { - return []; + $parsedInput->fields = []; + return; } foreach ($characters as $character) { @@ -66,28 +72,39 @@ public function parseFields($rawFields): array $context->isQuoted = ! $context->isQuoted; continue 2; case ',': - $context->stack[] = $context->accumulator; - $context->accumulator = ''; + // This can be empty when, for example, the comma follows the + // ending of a relation and the accumulator has already been cleared: + // "id,comments(author(id),id)" + // ---^ + if (! empty($context->accumulator)) { + $context->addField($context->accumulator); + $context->accumulator = ''; + } continue 2; case '(': // We've encountered a relationship. Parse everything until ")" // into a new context after which we revert the context back to // the parent. - $context = $context->makeChildContext(); + $relation = new Relation($context->accumulator); + $context = $context->makeChildContext($relation); continue 2; case ')': - // Add remainder to the current stack. - $context->stack[] = $context->accumulator; + if (! empty($context->accumulator)) { + // Add remainder to the current stack. + $context->addField($context->accumulator); + } - // For phpstan to understand that parent is filled at this point. + // For phpstan to understand that parent is filled at this + // point, and the stack is a relation. assert($context->parent !== null); + assert($context->stack instanceof Relation); - // The parent's accumulator currently holds anything up until - // the (, which should be the relationship name - $context->parent->stack[] = new Relation($context->parent->accumulator, $context->stack); - $context->parent->accumulator = ''; + // Add the current stack (a relation) to the parent. + $context->parent->addRelation($context->stack); + // Cleanup and return context to the parent. $context = $context->parent; + $context->accumulator = ''; continue 2; default: $context->accumulator .= $character; @@ -98,35 +115,32 @@ public function parseFields($rawFields): array // a field. For example: "id,name" will still have "name" in the // accumulator when the string ends. if (! empty($context->accumulator)) { - $context->stack[] = $context->accumulator; + $context->addField($context->accumulator); } - - return $context->stack; } /** + * @param ParsedInput $parsedInput * @param mixed|array|null $rawFilters - * - * @return array */ - public function parseFilters($rawFilters): array + public function parseFilters(ParsedInput $parsedInput, $rawFilters): void { // We expect filters to be in the format of: // filters[search]=query // which means the filters must always be an (assoc) array. if (! is_array($rawFilters) || ! Arr::isAssoc($rawFilters)) { - return []; + $parsedInput->filters = []; + return; } - return $rawFilters; + $parsedInput->filters = $rawFilters; } /** + * @param ParsedInput $parsedInput * @param mixed|string[]|string $rawSorts - * - * @return Sort[] */ - public function parseSorts($rawSorts): array + public function parseSorts(ParsedInput $parsedInput, $rawSorts): void { // Sort input examples: // "name" @@ -139,7 +153,8 @@ public function parseSorts($rawSorts): array if (! is_array($rawSorts)) { // We cannot parse this, ignore the given sorting. - return []; + $parsedInput->sorts = []; + return; } $sorts = []; @@ -167,7 +182,7 @@ public function parseSorts($rawSorts): array $sorts[] = new Sort($field, $order); } - return $sorts; + $parsedInput->sorts = $sorts; } /** diff --git a/src/Apitizer/Parser/ParsedInput.php b/src/Apitizer/Parser/ParsedInput.php index 8de6f4a..b9bc73f 100644 --- a/src/Apitizer/Parser/ParsedInput.php +++ b/src/Apitizer/Parser/ParsedInput.php @@ -5,11 +5,15 @@ class ParsedInput { /** - * @var (string|Relation)[] an array of either strings (plain columns) or - * Relation objects which denote associations. + * @var string[] */ public $fields = []; + /** + * @var Relation[] + */ + public $associations = []; + /** * @var Sort[] */ @@ -19,4 +23,14 @@ class ParsedInput * @var array */ public $filters = []; + + public function addField(string $field): void + { + $this->fields[] = $field; + } + + public function addRelation(Relation $relation): void + { + $this->associations[] = $relation; + } } diff --git a/src/Apitizer/Parser/Relation.php b/src/Apitizer/Parser/Relation.php index 5a48921..018cd40 100644 --- a/src/Apitizer/Parser/Relation.php +++ b/src/Apitizer/Parser/Relation.php @@ -7,15 +7,24 @@ class Relation /** @var string */ public $name; - /** @var array */ + /** @var string[] */ public $fields; - /** - * @param string $name - * @param array $fields - */ - public function __construct(string $name, array $fields) { + /** @var Relation[] */ + public $associations = []; + + public function __construct(string $name) + { $this->name = $name; - $this->fields = $fields; + } + + public function addField(string $field): void + { + $this->fields[] = $field; + } + + public function addRelation(Relation $relation): void + { + $this->associations[] = $relation; } } diff --git a/src/Apitizer/QueryBuilder.php b/src/Apitizer/QueryBuilder.php index 150b62d..dcec702 100644 --- a/src/Apitizer/QueryBuilder.php +++ b/src/Apitizer/QueryBuilder.php @@ -4,15 +4,13 @@ use Apitizer\Exceptions\ApitizerException; use Apitizer\Exceptions\DefinitionException; -use Apitizer\Exceptions\InvalidInputException; use Apitizer\ExceptionStrategy\Strategy; use Apitizer\Interpreter\QueryInterpreter; -use Apitizer\Parser\ParsedInput; use Apitizer\Parser\Parser; use Apitizer\Parser\RawInput; -use Apitizer\Parser\Relation; use Apitizer\Rendering\Renderer; use Apitizer\Support\DefinitionHelper; +use Apitizer\Support\FetchSpecFactory; use Apitizer\Types\Apidoc; use Apitizer\Types\Association; use Apitizer\Types\FetchSpec; @@ -69,10 +67,17 @@ abstract class QueryBuilder /** * The result of the fields() callback. * - * @var (AbstractField|Association)[]|null + * @var AbstractField[]|null */ protected $availableFields; + /** + * The result of the associations() callback + * + * @var Association[]|null + */ + protected $availableAssociations; + /** * The results of the sorts() function. * @@ -134,10 +139,20 @@ abstract class QueryBuilder * is used to fetch the data from the Eloquent model, so it usually * corresponds to the column name in the database. * - * @return array + * @return array */ abstract public function fields(): array; + /** + * A callback that returns all the associations that are available to the + * client. + * + * @see QueryBuilder::association + * + * @return array + */ + abstract public function associations(): array; + /** * A function that returns the names of the sorting methods that are * available to the client. @@ -329,7 +344,7 @@ public function render($data): array { $fetchSpec = $this->makeFetchSpecification(); - return $this->getRenderer()->render($this, $data, $fetchSpec->getFields()); + return $this->getRenderer()->render($this, $data, $fetchSpec); } /** @@ -344,7 +359,7 @@ public function all(): array return $this->getRenderer()->render( $this, $this->getQueryInterpreter()->build($this, $fetchSpec)->get(), - $fetchSpec->getFields() + $fetchSpec ); } @@ -367,7 +382,7 @@ public function paginate(int $perPage = null, $pageName = 'page', $page = null): return tap($paginator, function (AbstractPaginator $paginator) use ($fetchSpec) { $renderedData = $this->getRenderer()->render( - $this, $paginator->getCollection(), $fetchSpec->getFields() + $this, $paginator->getCollection(), $fetchSpec ); $paginator->setCollection(collect($renderedData)); @@ -458,135 +473,35 @@ protected function makeFetchSpecification(): FetchSpec ? RawInput::fromRequest($this->getRequest()) : RawInput::fromArray($this->specification); - $fetchSpec = $this->validateRequestInput( - $this->getParser()->parse($rawInput) - ); - - return $fetchSpec; - } - - protected function validateRequestInput(ParsedInput $unvalidatedInput): FetchSpec - { - $validated = new FetchSpec( - $this->getValidatedFields($unvalidatedInput->fields, $this->getFields()), - $this->getValidatedSorting($unvalidatedInput->sorts, $this->getSorts()), - $this->getValidatedFilters($unvalidatedInput->filters, $this->getFilters()) + return FetchSpecFactory::fromRequestInput( + $this->getParser()->parse($rawInput), $this ); - - return $validated; } /** - * Validate the fields that were requested by the client. - * - * @param (string|Relation)[] $unvalidatedFields - * @param array $availableFields - * - * @return array + * @return array */ - protected function getValidatedFields(array $unvalidatedFields, array $availableFields): array - { - $validatedFields = []; - - // Essentially get a subset of $availableFields with some type juggling - // along the way. - foreach ($unvalidatedFields as $field) { - if ($field instanceof Relation && isset($availableFields[$field->name])) { - // Convert the Parser\Relation to an Association object. - /** @var Association */ - $association = $availableFields[$field->name]; - - // Validate the fields recursively - $queryBuilder = $association->getRelatedQueryBuilder(); - $association->setFields( - $queryBuilder->getValidatedFields( - $field->fields, $queryBuilder->getFields() - ) - ); - - $validatedFields[] = $association; - continue; - } - - if (is_string($field) && isset($availableFields[$field])) { - $fieldInstance = $availableFields[$field]; - - // An association was selected without specifying any fields, e.g.: - // /posts?fields=comments - // .. so we select everything from that query builder. - if ($fieldInstance instanceof Association) { - $fieldInstance->setFields( - $fieldInstance->getRelatedQueryBuilder()->getOnlyFields() - ); - } - - $validatedFields[] = $fieldInstance; - } - } - - if (empty($validatedFields)) { - $validatedFields = $this->getOnlyFields(); - } - - return $validatedFields; - } - - /** - * @param \Apitizer\Parser\Sort[] $selectedSorts - * @param Sort[] $availableSorts - * - * @return Sort[] - */ - protected function getValidatedSorting(array $selectedSorts, array $availableSorts): array - { - $validatedSorts = []; - - foreach ($selectedSorts as $parserSort) { - if (isset($availableSorts[$parserSort->getField()])) { - $sort = $availableSorts[$parserSort->getField()]; - $sort->setOrder($parserSort->getOrder()); - $validatedSorts[] = $sort; - } - } - - return $validatedSorts; - } - - /** - * @param array $selectedFilters - * @param array $availableFilters - * - * @return array - */ - protected function getValidatedFilters(array $selectedFilters, array $availableFilters): array + public function getFields(): array { - $validatedFilters = []; - - foreach ($selectedFilters as $name => $filterInput) { - try { - if (is_string($name) && isset($availableFilters[$name])) { - $filter = $availableFilters[$name]; - $filter->setValue($filterInput); - $validatedFilters[$name] = $filter; - } - } catch (InvalidInputException $e) { - $this->getExceptionStrategy()->handle($this, $e); - } + if (is_null($this->availableFields)) { + $this->availableFields = DefinitionHelper::validateFields($this, $this->fields()); } - return $validatedFilters; + return $this->availableFields; } /** - * @return array + * @return array */ - public function getFields(): array + public function getAssociations(): array { - if (is_null($this->availableFields)) { - $this->availableFields = DefinitionHelper::validateFields($this, $this->fields()); + if (is_null($this->availableAssociations)) { + $this->availableAssociations = DefinitionHelper::validateAssociations( + $this, $this->associations() + ); } - return $this->availableFields; + return $this->availableAssociations; } /** @@ -613,16 +528,6 @@ public function getFilters(): array return $this->availableFilters; } - /** - * @return AbstractField[] - */ - public function getOnlyFields(): array - { - return array_filter($this->getFields(), function ($field) { - return $field instanceof AbstractField; - }); - } - /** * Traverse the parents to find out if any of them are of the same instance * as the given class name. diff --git a/src/Apitizer/Rendering/AbstractRenderer.php b/src/Apitizer/Rendering/AbstractRenderer.php new file mode 100644 index 0000000..f9268ed --- /dev/null +++ b/src/Apitizer/Rendering/AbstractRenderer.php @@ -0,0 +1,28 @@ +|object|iterable|mixed $data + */ + protected function isSingleRowOfData($data): bool + { + return + // Distinguish between arrays as lists of data, or arrays as maps. + // Associative arrays (maps) are considered a single row of data. + (is_array($data) && Arr::isAssoc($data)) + + // Distinguish between e.g. Eloquent objects and Collection objects. + // Non-iterable objects are considered a single row of data. + || (is_object($data) && !is_iterable($data)); + } +} diff --git a/src/Apitizer/Rendering/BasicRenderer.php b/src/Apitizer/Rendering/BasicRenderer.php index 831214d..b85c0ea 100644 --- a/src/Apitizer/Rendering/BasicRenderer.php +++ b/src/Apitizer/Rendering/BasicRenderer.php @@ -4,71 +4,132 @@ use Apitizer\Policies\PolicyFailed; use Apitizer\QueryBuilder; +use Apitizer\Types\FetchSpec; use Apitizer\Types\AbstractField; use Apitizer\Types\Association; -use Illuminate\Support\Arr; +use Apitizer\Exceptions\InvalidOutputException; -class BasicRenderer implements Renderer +class BasicRenderer extends AbstractRenderer implements Renderer { - public function render(QueryBuilder $queryBuilder, $data, array $selectedFields): array + public function render(QueryBuilder $queryBuilder, $data, FetchSpec $fetchSpec): array { + return $this->doRender( + $queryBuilder, $data, + $fetchSpec->getFields(), + $fetchSpec->getAssociations() + ); + } + + /** + * @param QueryBuilder $queryBuilder + * @param mixed $data + * @param AbstractField[] $fields + * @param Association[] $associations + * + * @return array|array> + */ + protected function doRender( + QueryBuilder $queryBuilder, + $data, + array $fields, + array $associations + ): array { if ($this->isSingleRowOfData($data)) { - return $this->renderOne($data, $selectedFields); + return $this->renderSingleRow($data, $queryBuilder, $fields, $associations); + } else { + return $this->renderMany($data, $queryBuilder, $fields, $associations); } + } - $result = []; + /** + * @param mixed $data + * @param QueryBuilder $queryBuilder + * @param AbstractField[] $fields + * @param Association[] $associations + * + * @return array> + */ + protected function renderMany( + $data, + QueryBuilder $queryBuilder, + array $fields, + array $associations + ): array { + return collect($data)->map(function ($row) use ($queryBuilder, $fields, $associations) { + return $this->renderSingleRow($row, $queryBuilder, $fields, $associations); + })->all(); + } + + /** + * @param mixed $row + * @param QueryBuilder $queryBuilder + * @param AbstractField[] $fields + * @param Association[] $associations + * + * @return array + */ + protected function renderSingleRow( + $row, + QueryBuilder $queryBuilder, + array $fields, + array $associations + ): array { + $renderedData = []; - foreach ($data as $row) { - // When dealing with e.g. hasMany associations - if ($row instanceof PolicyFailed) { - continue; - } + foreach ($fields as $field) { + $this->addRenderedField($row, $field, $renderedData); + } - $result[] = $this->renderOne($row, $selectedFields); + foreach ($associations as $association) { + $this->addRenderedAssociation($row, $association, $renderedData); } - return $result; + return $renderedData; } /** * @param mixed $row - * @param (AbstractField|Association)[] $selectedFields + * @param AbstractField $field + * @param array $renderedData * - * @return array + * @throws InvalidOutputException if the value does not adhere to the + * requirements set by the field. For example, if the field is not + * nullable but the value is null, this will throw an error. Enum + * field may also throw an error if the value is not in the enum. */ - protected function renderOne($row, array $selectedFields): array - { - $acc = []; - - foreach ($selectedFields as $fieldOrAssoc) { - $renderedValue = $fieldOrAssoc->render($row, $this); + protected function addRenderedField( + $row, + AbstractField $field, + array &$renderedData + ): void { + $value = $field->render($row, $this); - // When a specific value fails, or when an association that returns - // a single row fails (e.g. belongsTo) - if ($renderedValue instanceof PolicyFailed) { - continue; - } - - $acc[$fieldOrAssoc->getName()] = $renderedValue; + if ($value instanceof PolicyFailed) { + return; } - return $acc; + $renderedData[$field->getName()] = $value; } /** - * Check if we're dealing with a single row of data or a collection of rows. - * - * @param array|object|iterable|mixed $data + * @param mixed $row + * @param Association $association + * @param array $renderedData */ - protected function isSingleRowOfData($data): bool - { - return - // Distinguish between arrays as lists of data, or arrays as maps. - // Associative arrays (maps) are considered a single row of data. - (is_array($data) && Arr::isAssoc($data)) + protected function addRenderedAssociation( + $row, + Association $association, + array &$renderedData + ): void{ + $associationData = $this->valueFromRow($row, $association->getKey()); + + if (! $association->passesPolicy($associationData, $row)) { + return; + } - // Distinguish between e.g. Eloquent objects and Collection objects. - // Non-iterable objects are considered a single row of data. - || (is_object($data) && ! is_iterable($data)); + $renderedData[$association->getName()] = $this->doRender( + $association->getRelatedQueryBuilder(), $associationData, + $association->getFields() ?? [], $association->getAssociations() ?? [] + ); } } diff --git a/src/Apitizer/Rendering/JsonApiRenderer.php b/src/Apitizer/Rendering/JsonApiRenderer.php new file mode 100644 index 0000000..8be76b9 --- /dev/null +++ b/src/Apitizer/Rendering/JsonApiRenderer.php @@ -0,0 +1,86 @@ +isSingleRowOfData($data)) { + // $response['data'] = $this->renderOne($data, $selectedFields); + // return $response; + // } + + // return []; + // } + + // protected function renderOne(QueryBuilder $queryBuilder, $row, array $selectedFields): array + // { + // $resource = [ + // 'id' => $this->getResourceId($queryBuilder, $row), + // 'type' => $this->getResourceType($queryBuilder, $row), + // ]; + + // return []; + // } + + // protected function getResourceType(QueryBuilder $queryBuilder, $row) + // { + // if ($row instanceof Resource) { + // return $row->getResourceType(); + // } + + // $className = (new ReflectionClass($queryBuilder->model()))->getShortName(); + + // return Str::snake($className); + // } + + // protected function getResourceId(QueryBuilder $queryBuilder, $row): string + // { + // if ($row instanceof Resource) { + // return $row->getResourceId(); + // } + + // if ($row instanceof Model) { + // return (string) $row->getKey(); + // } + + // if (is_array($row) || $row instanceof ArrayAccess) { + // if (isset($row['id'])) { + // return (string) $row['id']; + // } + + // if (isset($row['uuid'])) { + // return (string) $row['uuid']; + // } + // } + + // if (is_object($row)) { + // if (isset($row->{'id'})) { + // return (string) $row->{'id'}; + // } + + // if (isset($row->{'uuid'})) { + // return (string) $row->{'uuid'}; + // } + // } + + // throw InvalidOutputException::noJsonApiIdentifier($queryBuilder, $row); + // } +// } diff --git a/src/Apitizer/Rendering/Renderer.php b/src/Apitizer/Rendering/Renderer.php index d925034..430dc34 100644 --- a/src/Apitizer/Rendering/Renderer.php +++ b/src/Apitizer/Rendering/Renderer.php @@ -3,8 +3,7 @@ namespace Apitizer\Rendering; use Apitizer\QueryBuilder; -use Apitizer\Types\Association; -use Apitizer\Types\AbstractField; +use Apitizer\Types\FetchSpec; /** * Describes a class that can render data for the query builder. @@ -16,8 +15,8 @@ interface Renderer * * @param QueryBuilder $queryBuilder * @param array|Collection|object|iterable $data - * @param (AbstractField|Association)[] $selectedFields + * @param FetchSpec $fetchSpec * @return array|array> */ - public function render(QueryBuilder $queryBuilder, $data, array $selectedFields): array; + public function render(QueryBuilder $queryBuilder, $data, FetchSpec $fetchSpec): array; } diff --git a/src/Apitizer/Support/DefinitionHelper.php b/src/Apitizer/Support/DefinitionHelper.php index 2f24721..75b33ab 100644 --- a/src/Apitizer/Support/DefinitionHelper.php +++ b/src/Apitizer/Support/DefinitionHelper.php @@ -22,7 +22,7 @@ class DefinitionHelper * @param QueryBuilder $queryBuilder * @param array $fields * - * @return (AbstractField|Association)[] + * @return array */ static function validateFields(QueryBuilder $queryBuilder, array $fields): array { @@ -38,24 +38,14 @@ static function validateFields(QueryBuilder $queryBuilder, array $fields): array /** * @param QueryBuilder $queryBuilder * @param string $name - * @param AbstractField|Association|mixed $field + * @param AbstractField|mixed $field * * @throws DefinitionException * - * @return AbstractField|Association + * @return AbstractField */ static function validateField(QueryBuilder $queryBuilder, string $name, $field) { - if ($field instanceof Association) { - $field->setName($name); - - if (! static::isValidAssociation($queryBuilder, $field)) { - throw DefinitionException::associationDoesNotExist($queryBuilder, $field); - } - - return $field; - } - if (is_string($field)) { $field = new Field($queryBuilder, $field, 'any'); } @@ -69,6 +59,48 @@ static function validateField(QueryBuilder $queryBuilder, string $name, $field) return $field; } + /** + * @param QueryBuilder $queryBuilder + * @param array $associations + * + * @return array + */ + static function validateAssociations(QueryBuilder $queryBuilder, array $associations): array + { + $castFields = []; + + foreach ($associations as $name => $association) { + $castFields[$name] = static::validateAssociation($queryBuilder, $name, $association); + } + + return $castFields; + } + + /** + * @param QueryBuilder $queryBuilder + * @param string $name + * @param Association|mixed $association + */ + static function validateAssociation( + QueryBuilder $queryBuilder, + string $name, + $association + ): Association { + if (! $association instanceof Association) { + throw DefinitionException::associationDefinitionExpected( + $queryBuilder, $name, $association + ); + } + + $association->setName($name); + + if (!static::isValidAssociation($queryBuilder, $association)) { + throw DefinitionException::associationDoesNotExist($queryBuilder, $association); + } + + return $association; + } + private static function isValidAssociation( QueryBuilder $queryBuilder, Association $association diff --git a/src/Apitizer/Support/FetchSpecFactory.php b/src/Apitizer/Support/FetchSpecFactory.php new file mode 100644 index 0000000..230d3dc --- /dev/null +++ b/src/Apitizer/Support/FetchSpecFactory.php @@ -0,0 +1,178 @@ +queryBuilder = $queryBuilder; + $this->input = $input; + } + + public static function fromRequestInput( + ParsedInput $input, + QueryBuilder $queryBuilder + ): FetchSpec { + return (new static($queryBuilder, $input))->make(); + } + + public function make(): FetchSpec + { + $fields = $this->selectedFields($this->queryBuilder, $this->input->fields); + $associations = $this->selectedAssociations( + $this->queryBuilder, $this->input->associations, $this->input->fields + ); + + // If nothing was (correct) was selected, return all the default fields + // but no associations. + if (empty($fields) && empty($associations)) { + $fields = $this->queryBuilder->getFields(); + } + + $sorts = $this->selectedSorting(); + $filters = $this->selectedFilters(); + + return new FetchSpec($fields, $associations, $sorts, $filters); + } + + /** + * @param QueryBuilder $queryBuilder + * @param string[] $requestedFields + * + * @return AbstractField[] + */ + protected function selectedFields(QueryBuilder $queryBuilder, array $requestedFields): array + { + $availableFields = $queryBuilder->getFields(); + /** @var AbstractField[] $selectedFields */ + $selectedFields = []; + + foreach ($requestedFields as $field) { + if (is_string($field) && isset($availableFields[$field])) { + $selectedFields[] = $availableFields[$field]; + } + } + + return $selectedFields; + } + + /** + * @param QueryBuilder $queryBuilder + * @param Relation[] $relations + * @param string[] $fields + * + * @return Association[] + */ + protected function selectedAssociations( + QueryBuilder $queryBuilder, + array $relations, + array $fields + ): array { + $availableAssociations = $queryBuilder->getAssociations(); + $selectedAssociations = []; + + // Recursively select associations and their fields as specified by the + // client. + foreach ($relations as $relation) { + if (isset($availableAssociations[$relation->name])) { + $association = $availableAssociations[$relation->name]; + $relatedBuilder = $association->getRelatedQueryBuilder(); + + $association->setFields( + $this->selectedFields($relatedBuilder, $relation->fields) + ); + $association->setAssociations( + $this->selectedAssociations( + $relatedBuilder, $relation->associations, $relation->fields + ) + ); + + if (empty($association->getFields()) && empty($association->getAssociations())) { + $association->setFields($relatedBuilder->getFields()); + } + + $selectedAssociations[] = $association; + } + } + + // Additionally, merge in any associations that were select using only + // their name. For these associations only the fields will be selected. + foreach ($fields as $field) { + if (is_string($field) && isset($availableAssociations[$field])) { + $association = $availableAssociations[$field]; + $association->setFields($association->getRelatedQueryBuilder()->getFields()); + $selectedAssociations[] = $association; + } + } + + return $selectedAssociations; + } + + /** + * @return Sort[] + */ + protected function selectedSorting(): array + { + $availableSorting = $this->queryBuilder->getSorts(); + $selectedSorting = []; + + foreach ($this->input->sorts as $parserSort) { + if (isset($availableSorting[$parserSort->getField()])) { + $sort = $availableSorting[$parserSort->getField()]; + $sort->setOrder($parserSort->getOrder()); + $selectedSorting[] = $sort; + } + } + + return $selectedSorting; + } + + /** + * @return Filter[] + */ + protected function selectedFilters(): array + { + $availableFilters = $this->queryBuilder->getFilters(); + $selectedFilters = []; + + foreach ($this->input->filters as $name => $filterInput) { + try { + if (isset($availableFilters[$name])) { + $filter = $availableFilters[$name]; + $filter->setValue($filterInput); + $selectedFilters[] = $filter; + } + } catch (InvalidInputException $e) { + $this->queryBuilder->getExceptionStrategy()->handle( + $this->queryBuilder, $e + ); + } + } + + return $selectedFilters; + } +} diff --git a/src/Apitizer/Support/SchemaValidator.php b/src/Apitizer/Support/SchemaValidator.php index dbccd99..23cc049 100644 --- a/src/Apitizer/Support/SchemaValidator.php +++ b/src/Apitizer/Support/SchemaValidator.php @@ -41,6 +41,9 @@ public function validate(QueryBuilder $queryBuilder): self $this->catchAll(function () use ($queryBuilder) { $this->validateFields($queryBuilder); }); + $this->catchAll(function () use ($queryBuilder) { + $this->validateAssociations($queryBuilder); + }); $this->catchAll(function () use ($queryBuilder) { $this->validateFilters($queryBuilder); }); @@ -63,6 +66,17 @@ public function validateFields(QueryBuilder $queryBuilder): void } } + public function validateAssociations(QueryBuilder $queryBuilder): void + { + foreach ($queryBuilder->associations() as $name => $field) { + try { + DefinitionHelper::validateAssociation($queryBuilder, $name, $field); + } catch (DefinitionException $e) { + $this->errors[] = $e; + } + } + } + public function validateFilters(QueryBuilder $queryBuilder): void { foreach ($queryBuilder->filters() as $name => $filter) { diff --git a/src/Apitizer/Types/AbstractField.php b/src/Apitizer/Types/AbstractField.php index 4d3b390..682477d 100644 --- a/src/Apitizer/Types/AbstractField.php +++ b/src/Apitizer/Types/AbstractField.php @@ -96,7 +96,7 @@ public function render($row, Renderer $renderer = null) { $value = $this->validateValue($this->getValue($row), $row); - if (! $this->passesPolicy($value, $row, $this)) { + if (! $this->passesPolicy($value, $row)) { return new PolicyFailed; } @@ -141,7 +141,7 @@ protected function applyTransformers($value, $row) * * @return mixed the value if it was valid. */ - protected function validateValue($value, $row) + public function validateValue($value, $row) { if (is_null($value) && !$this->isNullable()) { throw InvalidOutputException::fieldIsNull($this, $row); diff --git a/src/Apitizer/Types/Apidoc.php b/src/Apitizer/Types/Apidoc.php index d15f038..eb70f96 100644 --- a/src/Apitizer/Types/Apidoc.php +++ b/src/Apitizer/Types/Apidoc.php @@ -26,16 +26,6 @@ class Apidoc */ protected $name; - /** - * @var AbstractField[] - */ - protected $fields = []; - - /** - * @var Association[] - */ - protected $associations = []; - /** * @var string A description of this resource. */ @@ -52,16 +42,6 @@ public function __construct(QueryBuilder $queryBuilder) $this->queryBuilder = $queryBuilder; $this->setName($this->guessQueryBuilderResourceName()); - foreach ($queryBuilder->getFields() as $field) { - if ($field instanceof AbstractField) { - $this->fields[] = $field; - } - - if ($field instanceof Association) { - $this->associations[] = $field; - } - } - $queryBuilder->apidoc($this); } @@ -94,7 +74,7 @@ public function setName(string $name): self */ public function getFields(): array { - return $this->fields; + return $this->queryBuilder->getFields(); } /** @@ -102,7 +82,7 @@ public function getFields(): array */ public function getAssociations(): array { - return $this->associations; + return $this->queryBuilder->getAssociations(); } /** diff --git a/src/Apitizer/Types/Association.php b/src/Apitizer/Types/Association.php index 463dcf1..2ee8faf 100644 --- a/src/Apitizer/Types/Association.php +++ b/src/Apitizer/Types/Association.php @@ -20,11 +20,16 @@ class Association extends Factory protected $key; /** - * @var null|(AbstractField|Association)[] The fields to render - * on the related query builder. + * @var null|AbstractField[] The fields to render on the related query + * builder. */ protected $fields; + /** + * @var null|Association[] + */ + protected $associations; + /** * @var QueryBuilder the query builder that renders the associated data. */ @@ -41,46 +46,37 @@ public function __construct( } /** - * @param mixed $row - * @param Renderer $renderer - * - * @return mixed|PolicyFailed + * @return AbstractField[] */ - public function render($row, Renderer $renderer) + public function getFields(): ?array { - $assocData = $this->valueFromRow($row, $this->getKey()); - - if ($this->returnsCollection()) { - foreach ($assocData as $key => $value) { - if (! $this->passesPolicy($value, $row, $this)) { - $assocData[$key] = new PolicyFailed; - } - } - } else { - if (! $this->passesPolicy($assocData, $row, $this)) { - return new PolicyFailed; - } - } - - return $renderer->render( - $this->getRelatedQueryBuilder(), $assocData, $this->fields ?? [] - ); + return $this->fields; } /** - * @return (AbstractField|Association)[] + * @param AbstractField[] $fields */ - public function getFields(): ?array + public function setFields(array $fields): self { - return $this->fields; + $this->fields = $fields; + + return $this; } /** - * @param (AbstractField|Association)[] $fields + * @return Association[] */ - public function setFields(array $fields): self + public function getAssociations(): ?array { - $this->fields = $fields; + return $this->associations; + } + + /** + * @param Association[] $associations + */ + public function setAssociations(array $associations): self + { + $this->associations = $associations; return $this; } diff --git a/src/Apitizer/Types/Concerns/HasPolicy.php b/src/Apitizer/Types/Concerns/HasPolicy.php index 254e5ff..e6a2b8d 100644 --- a/src/Apitizer/Types/Concerns/HasPolicy.php +++ b/src/Apitizer/Types/Concerns/HasPolicy.php @@ -57,12 +57,11 @@ public function policyAny(Policy ...$policies): self * * @param mixed $value * @param array|Model|mixed $row - * @param AbstractField|Association $fieldOrAssoc */ - protected function passesPolicy($value, $row, $fieldOrAssoc): bool + public function passesPolicy($value, $row): bool { foreach ($this->policies as $policy) { - if (! $policy->passes($value, $row, $fieldOrAssoc)) { + if (! $policy->passes($value, $row, $this)) { return false; } } diff --git a/src/Apitizer/Types/EnumField.php b/src/Apitizer/Types/EnumField.php index 8a4bfd3..6418078 100644 --- a/src/Apitizer/Types/EnumField.php +++ b/src/Apitizer/Types/EnumField.php @@ -34,7 +34,7 @@ public function __construct( $this->enum = $enum; } - protected function validateValue($value, $row) + public function validateValue($value, $row) { $value = parent::validateValue($value, $row); diff --git a/src/Apitizer/Types/FetchSpec.php b/src/Apitizer/Types/FetchSpec.php index 66d4fdf..8182da1 100644 --- a/src/Apitizer/Types/FetchSpec.php +++ b/src/Apitizer/Types/FetchSpec.php @@ -17,10 +17,17 @@ class FetchSpec /** * The fields that should be fetched. * - * @var (AbstractField|Association)[] + * @var AbstractField[] */ protected $fields = []; + /** + * The associations that should be fetched. + * + * @var Association[] + */ + protected $associations; + /** * The sorting methods that should be applied. * @@ -36,19 +43,25 @@ class FetchSpec protected $filters = []; /** - * @param (AbstractField|Association)[] $fields + * @param AbstractField[] $fields + * @param Association[] $associations * @param Sort[] $sorts * @param Filter[] $filters */ - public function __construct(array $fields = [], array $sorts = [], array $filters = []) - { + public function __construct( + array $fields = [], + array $associations = [], + array $sorts = [], + array $filters = [] + ) { $this->fields = $fields; + $this->associations = $associations; $this->sorts = $sorts; $this->filters = $filters; } /** - * @return (AbstractField|Association)[] + * @return AbstractField[] */ public function getFields(): array { @@ -71,11 +84,24 @@ public function getFilters(): array return $this->filters; } + /** + * @return Association[] + */ + public function getAssociations(): array + { + return $this->associations; + } + public function fieldSelected(string $name): bool { return $this->hasName($this->getFields(), $name); } + public function associationSelected(string $name): bool + { + return $this->hasName($this->getAssociations(), $name); + } + public function filterSelected(string $name): bool { return $this->hasName($this->getFilters(), $name); diff --git a/tests/Feature/Commands/ValidateSchemaCommandTest.php b/tests/Feature/Commands/ValidateSchemaCommandTest.php index 42f07bf..7dc46b1 100644 --- a/tests/Feature/Commands/ValidateSchemaCommandTest.php +++ b/tests/Feature/Commands/ValidateSchemaCommandTest.php @@ -101,7 +101,7 @@ public function it_should_list_any_unexpected_exceptions_that_occurred() class NotABuilder{} class AssociationDoesNotExist extends EmptyBuilder { - public function fields(): array + public function associations(): array { return [ 'geckos' => $this->association('geckos', UserBuilder::class), diff --git a/tests/Feature/QueryBuilder/PolicyTest.php b/tests/Feature/QueryBuilder/PolicyTest.php index bca83bb..d1f12a2 100644 --- a/tests/Feature/QueryBuilder/PolicyTest.php +++ b/tests/Feature/QueryBuilder/PolicyTest.php @@ -119,25 +119,6 @@ public function policies_can_easily_be_cached_and_shared_amongst_fields() $this->assertEquals(1, $policy->called); } - /** @test */ - public function policies_are_applied_for_each_row_in_an_association() - { - $user = factory(User::class)->state('withPosts')->create(); - // We're going to fail the policy for the first post, all others will - // succeed. - $policy = new FailId($user->posts->first()->id); - $fields = [ - 'posts' => $this->association('posts', new PostBuilder())->policy($policy), - ]; - - $request = $this->request()->fields('posts(id)')->make(); - $result = PolicyTestBuilder::new($request, $fields)->render($user); - - $this->assertEquals([ - 'posts' => $user->posts->slice(1)->values()->map->only('id')->all() - ], $result, 'all but the first row should be filtered out'); - } - /** @test */ public function policies_are_applied_to_single_value_associations() { @@ -252,6 +233,12 @@ public function fields(): array { return [ 'id' => $this->int('id'), + ]; + } + + public function associations(): array + { + return [ 'posts' => $this->association('posts', PolicyPostBuilder::class), ]; } @@ -264,8 +251,14 @@ public function fields(): array return [ 'id' => $this->int('id'), 'title' => $this->string('name')->policy(new FalseP), + ]; + } + + public function associations(): array + { + return [ 'author' => $this->association('author', PolicyUserBuilder::class) - ->policy(new FalseP), + ->policy(new FalseP), ]; } diff --git a/tests/Feature/QueryBuilder/SelectTest.php b/tests/Feature/QueryBuilder/SelectTest.php index 3f94a33..88398da 100644 --- a/tests/Feature/QueryBuilder/SelectTest.php +++ b/tests/Feature/QueryBuilder/SelectTest.php @@ -87,8 +87,7 @@ public function if_no_fields_are_selected_all_non_association_fields_are_returne public function if_no_valid_fields_are_selected_in_an_association_all_fields_are_returned() { $post = factory(Post::class)->state('withComments')->create(); - // comments doesn't have an 'authors' assoc/field. - $request = $this->request()->fields('id,comments(authors)')->make(); + $request = $this->request()->fields('id,comments(unknown)')->make(); $result = PostBuilder::make($request)->render($post); $this->assertEquals([ @@ -193,6 +192,12 @@ public function fields(): array { return [ 'id' => $this->int('id'), + ]; + } + + public function associations(): array + { + return [ 'posts' => $this->association('posts', LoadRelatedColumn::class), ]; } diff --git a/tests/RequestBuilder.php b/tests/RequestBuilder.php index b5ea8a9..45d37be 100644 --- a/tests/RequestBuilder.php +++ b/tests/RequestBuilder.php @@ -97,7 +97,7 @@ public function make(): Request } if (! is_null($this->limit)) { - $queryParams['limit'] = $this->limit; + $queryParams[Apitizer::getLimitKey()] = $this->limit; } $request = Request::create($this->url, $this->method); diff --git a/tests/Support/Builders/CommentBuilder.php b/tests/Support/Builders/CommentBuilder.php index 956a54d..42b0cf4 100644 --- a/tests/Support/Builders/CommentBuilder.php +++ b/tests/Support/Builders/CommentBuilder.php @@ -12,6 +12,12 @@ public function fields(): array return [ 'id' => $this->int('id'), 'body' => $this->string('body'), + ]; + } + + public function associations(): array + { + return [ 'author' => $this->association('author', UserBuilder::class), ]; } diff --git a/tests/Support/Builders/EmptyBuilder.php b/tests/Support/Builders/EmptyBuilder.php index d35e5de..f41d858 100644 --- a/tests/Support/Builders/EmptyBuilder.php +++ b/tests/Support/Builders/EmptyBuilder.php @@ -14,6 +14,11 @@ public function fields(): array return []; } + public function associations(): array + { + return []; + } + public function filters(): array { return []; diff --git a/tests/Support/Builders/PostBuilder.php b/tests/Support/Builders/PostBuilder.php index 7c0054b..ac865a6 100644 --- a/tests/Support/Builders/PostBuilder.php +++ b/tests/Support/Builders/PostBuilder.php @@ -21,6 +21,12 @@ public function fields(): array 'total' => $this->generatedField('string', function ($row) { return \strlen($row->title); }), + ]; + } + + public function associations(): array + { + return [ 'author' => $this->association('author', UserBuilder::class), 'comments' => $this->association('comments', CommentBuilder::class), 'tags' => $this->association('tags', TagBuilder::class), diff --git a/tests/Support/Builders/UserBuilder.php b/tests/Support/Builders/UserBuilder.php index f60572e..ee36422 100644 --- a/tests/Support/Builders/UserBuilder.php +++ b/tests/Support/Builders/UserBuilder.php @@ -16,6 +16,12 @@ public function fields(): array 'should_reset_password' => $this->boolean('should_reset_password'), 'created_at' => $this->datetime('created_at')->format(), 'updated_at' => $this->date('updated_at')->format(), + ]; + } + + public function associations(): array + { + return [ 'posts' => $this->association('posts', PostBuilder::class), ]; } diff --git a/tests/Unit/Parser/FieldParsingTest.php b/tests/Unit/Parser/FieldParsingTest.php index e4a9cd6..9c763a1 100644 --- a/tests/Unit/Parser/FieldParsingTest.php +++ b/tests/Unit/Parser/FieldParsingTest.php @@ -4,6 +4,7 @@ use Apitizer\Parser\InputParser; use Apitizer\Parser\Relation; +use Apitizer\Parser\ParsedInput; use Tests\Unit\TestCase; class FieldParsingTest extends TestCase @@ -11,7 +12,7 @@ class FieldParsingTest extends TestCase /** @test */ public function it_parses_non_nested_fields() { - $fields = $this->parse('id,name'); + $fields = $this->parse('id,name')->fields; $this->assertEquals(2, count($fields)); $this->assertEquals($fields, ['id', 'name']); @@ -20,52 +21,55 @@ public function it_parses_non_nested_fields() /** @test */ public function it_parses_relationships_fields() { - $fields = $this->parse('id,name,comments(id,body)'); + $parsedInput = $this->parse('id,name,comments(id,body)'); - $this->assertEquals(3, count($fields)); - $this->assertInstanceOf(Relation::class, $fields[2]); - $this->assertEquals($fields[2]->name, 'comments'); - $this->assertEquals($fields[2]->fields, ['id', 'body']); + $this->assertEquals(2, count($parsedInput->fields)); + $this->assertEquals(1, count($parsedInput->associations)); + $this->assertInstanceOf(Relation::class, $parsedInput->associations[0]); + $this->assertEquals($parsedInput->associations[0]->name, 'comments'); + $this->assertEquals($parsedInput->associations[0]->fields, ['id', 'body']); } /** @test */ public function it_parses_nested_relationships_fields() { - $fields = $this->parse('id,name,comments(id,author(id))'); + $parsedInput = $this->parse('id,name,comments(author(id), id)'); - $this->assertEquals(3, count($fields)); - $this->assertInstanceOf(Relation::class, $fields[2]); - $this->assertInstanceOf(Relation::class, $fields[2]->fields[1]); - $this->assertEquals('author', $fields[2]->fields[1]->name); + $this->assertEquals(2, count($parsedInput->fields)); + $this->assertInstanceOf(Relation::class, $parsedInput->associations[0]); + $this->assertInstanceOf(Relation::class, $parsedInput->associations[0]->associations[0]); + $this->assertEquals('author', $parsedInput->associations[0]->associations[0]->name); } /** @test */ public function it_supports_quoted_expressions_in_fields() { - $fields = $this->parse('id,"full,name",comments(id,"raw),-(body")'); + $parsedInput = $this->parse('id,"full,name",comments(id,"raw),-(body")'); - $this->assertEquals(3, count($fields)); - $this->assertEquals('full,name', $fields[1]); - $this->assertEquals('raw),-(body', $fields[2]->fields[1]); + $this->assertEquals(2, count($parsedInput->fields)); + $this->assertEquals('full,name', $parsedInput->fields[1]); + $this->assertEquals('raw),-(body', $parsedInput->associations[0]->fields[1]); } /** @test */ public function white_space_characters_in_fields_are_ignored() { - $fields = $this->parse("id , name ,\r\ncomments(\u{200B} id , name )"); + $parsedInput = $this->parse("id , name ,\r\ncomments(\u{200B} id , name )"); + $fields = $parsedInput->fields; + $comments = $parsedInput->associations[0]; - $this->assertEquals(3, count($fields)); + $this->assertEquals(2, count($fields)); $this->assertEquals('id', $fields[0]); $this->assertEquals('name', $fields[1]); - $this->assertEquals('comments', $fields[2]->name); - $this->assertEquals('id', $fields[2]->fields[0]); - $this->assertEquals('name', $fields[2]->fields[1]); + $this->assertEquals('comments', $comments->name); + $this->assertEquals('id', $comments->fields[0]); + $this->assertEquals('name', $comments->fields[1]); } /** @test */ public function white_space_in_quoted_expressions_are_kept() { - $fields = $this->parse('"id, and name", test'); + $fields = $this->parse('"id, and name", test')->fields; $this->assertEquals(2, count($fields)); $this->assertEquals('id, and name', $fields[0]); @@ -74,7 +78,7 @@ public function white_space_in_quoted_expressions_are_kept() /** @test */ public function the_fields_may_be_an_array_of_strings() { - $fields = $this->parse(['id', 'name']); + $fields = $this->parse(['id', 'name'])->fields; $this->assertEquals(2, count($fields)); $this->assertEquals(['id', 'name'], $fields); } @@ -83,13 +87,17 @@ public function the_fields_may_be_an_array_of_strings() public function no_parsing_is_performed_when_an_array_is_passed() { $input = ['id', 'name', 'comments(id,name)', 'comments' => ['id', 'name']]; - $fields = $this->parse($input); + $fields = $this->parse($input)->fields; $this->assertSame($input, $fields); } - private function parse($fields) + private function parse($fields): ParsedInput { - return (new InputParser())->parseFields($fields); + $parsedInput = new ParsedInput; + + (new InputParser())->parseFields($parsedInput, $fields); + + return $parsedInput; } } diff --git a/tests/Unit/Parser/FilterParserTest.php b/tests/Unit/Parser/FilterParserTest.php index 25a76b9..e75a1ef 100644 --- a/tests/Unit/Parser/FilterParserTest.php +++ b/tests/Unit/Parser/FilterParserTest.php @@ -3,6 +3,7 @@ namespace Tests\Unit\Parser; use Apitizer\Parser\InputParser; +use Apitizer\Parser\ParsedInput; use Tests\Unit\TestCase; class FilterParserTest extends TestCase @@ -20,6 +21,8 @@ public function input_other_than_an_array_is_rejected() private function parse($data) { - return (new InputParser)->parseFilters($data); + $parsedInput = new ParsedInput(); + (new InputParser)->parseFilters($parsedInput, $data); + return $parsedInput->filters; } } diff --git a/tests/Unit/Parser/InputParserTest.php b/tests/Unit/Parser/InputParserTest.php index 38df8fb..c2b4ce1 100644 --- a/tests/Unit/Parser/InputParserTest.php +++ b/tests/Unit/Parser/InputParserTest.php @@ -26,7 +26,8 @@ public function it_parses_requests() $input = (new InputParser())->parse(RawInput::fromRequest($request)); $this->assertInstanceOf(ParsedInput::class, $input); - $this->assertEquals(3, count($input->fields)); + $this->assertEquals(2, count($input->fields)); + $this->assertEquals(1, count($input->associations)); $this->assertEquals(['name' => 'Hornstock'], $input->filters); $this->assertEquals(2, count($input->sorts)); } diff --git a/tests/Unit/Parser/SortParsingTest.php b/tests/Unit/Parser/SortParsingTest.php index 6961855..7016665 100644 --- a/tests/Unit/Parser/SortParsingTest.php +++ b/tests/Unit/Parser/SortParsingTest.php @@ -4,6 +4,7 @@ use Apitizer\Exceptions\InvalidInputException; use Apitizer\Parser\InputParser; +use Apitizer\Parser\ParsedInput; use Apitizer\Parser\Sort; use Tests\Unit\TestCase; @@ -89,6 +90,8 @@ public function an_exception_is_raised_if_an_invalid_array_is_passed() private function parse($sort) { - return (new InputParser())->parseSorts($sort); + $parsedInput = new ParsedInput; + (new InputParser())->parseSorts($parsedInput, $sort); + return $parsedInput->sorts; } } diff --git a/tests/Unit/Types/FetchSpecTest.php b/tests/Unit/Types/FetchSpecTest.php index 15b7f69..02c6018 100644 --- a/tests/Unit/Types/FetchSpecTest.php +++ b/tests/Unit/Types/FetchSpecTest.php @@ -2,6 +2,7 @@ namespace Tests\Unit\Types; +use Apitizer\Types\Association; use Apitizer\Types\FetchSpec; use Apitizer\Types\Field; use Apitizer\Types\Filter; @@ -24,7 +25,7 @@ public function the_caller_can_check_if_a_field_was_requested() /** @test */ public function the_caller_can_check_if_a_filter_was_requested() { - $fetchSpec = new FetchSpec([], [], [$this->filterWithName('name')]); + $fetchSpec = new FetchSpec([], [], [], [$this->filterWithName('name')]); $this->assertTrue($fetchSpec->filterSelected('name')); $fetchSpec = new FetchSpec(); @@ -34,12 +35,21 @@ public function the_caller_can_check_if_a_filter_was_requested() /** @test */ public function the_caller_can_check_if_specific_sorting_was_requested() { - $fetchSpec = new FetchSpec([], [$this->sortWithName('name')]); + $fetchSpec = new FetchSpec([], [], [$this->sortWithName('name')]); $this->assertTrue($fetchSpec->sortSelected('name')); $fetchSpec = new FetchSpec(); $this->assertFalse($fetchSpec->sortSelected('name')); } + /** @test */ + public function the_caller_can_check_if_an_association_was_selected() + { + $fetchSpec = new FetchSpec([], [$this->associationWithName('name')]); + $this->assertTrue($fetchSpec->associationSelected('name')); + + $fetchSpec = new FetchSpec(); + $this->assertFalse($fetchSpec->associationSelected('name')); + } private function fieldWithName(string $name) { @@ -55,4 +65,9 @@ private function sortWithName(string $name) { return (new Sort(new EmptyBuilder))->setName($name); } + + private function associationWithName(string $name) + { + return (new Association(new EmptyBuilder, new EmptyBuilder(), 'wow'))->setName($name); + } }