Skip to content

Commit

Permalink
Separate the associations from the fields in the QueryBuilder (#21)
Browse files Browse the repository at this point in the history
* Separates the associations from the fields

* Fix remove trailing comma for PHP7.2 compat
  • Loading branch information
drtheuns authored Feb 29, 2020
1 parent 468f4db commit 118d10d
Show file tree
Hide file tree
Showing 36 changed files with 868 additions and 400 deletions.
10 changes: 5 additions & 5 deletions resources/lang/en/validation.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
18 changes: 18 additions & 0 deletions src/Apitizer/Exceptions/DefinitionException.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions src/Apitizer/Exceptions/InvalidOutputException.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
92 changes: 50 additions & 42 deletions src/Apitizer/Interpreter/QueryInterpreter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand All @@ -40,58 +43,63 @@ 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();

// Always load the primary key in case there are relationships that
// 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));
Expand Down
20 changes: 20 additions & 0 deletions src/Apitizer/JsonApi/Resource.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

namespace Apitizer\JsonApi;

/**
* An interface that can be used on Eloquent models to tweak the output of that
* model in JsonApi responses.
*/
interface Resource
{
/**
* Get the value that should be used for the "type" field.
*/
public function getResourceType(): string;

/**
* Get the value that should be used for the "id" field.
*/
public function getResourceId(): string;
}
24 changes: 19 additions & 5 deletions src/Apitizer/Parser/Context.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ class Context
*
* In the case of field parsing, this might be the fields up until now.
*
* @var (string|Relation)[]
* @var Relation|ParsedInput
*/
public $stack = [];
public $stack;

/**
* The parent context.
Expand All @@ -43,12 +43,26 @@ class Context
*/
public $isQuoted = false;

public function __construct(Context $parent = null) {
/**
* @param Relation|ParsedInput $stack
*/
public function __construct($stack, Context $parent = null) {
$this->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);
}
}
Loading

0 comments on commit 118d10d

Please sign in to comment.