Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor relationship creation #4095

Merged
merged 16 commits into from
Jan 23, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
198 changes: 90 additions & 108 deletions src/app/Library/CrudPanel/Traits/Create.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,22 +21,24 @@ trait Create
/**
* Insert a row in the database.
*
* @param array $data All input values to be inserted.
* @param array $input All input values to be inserted.
* @return \Illuminate\Database\Eloquent\Model
*/
public function create($data)
public function create($input)
{
$data = $this->decodeJsonCastedAttributes($data);
$data = $this->compactFakeFields($data);
$data = $this->changeBelongsToNamesFromRelationshipToForeignKey($data);
$input = $this->decodeJsonCastedAttributes($input);
$input = $this->compactFakeFields($input);

// omit the n-n relationships when updating the eloquent item
$nn_relationships = Arr::pluck($this->getRelationFieldsWithPivot(), 'name');
$input = $this->changeBelongsToNamesFromRelationshipToForeignKey($input);

$item = $this->model->create(Arr::except($data, $nn_relationships));
$field_names_to_exclude = $this->getFieldNamesBeforeFirstDot($this->getRelationFieldsWithoutRelationType('BelongsTo', true));

// if there are any relationships available, also sync those
$this->createRelations($item, $data);
$item = $this->model->create(Arr::except($input, $field_names_to_exclude));

$relation_input = $this->getRelationDetailsFromInput($input);

// handle the creation of the model relations after the main entity is created.
$this->createRelationsForItem($item, $relation_input);

return $item;
}
Expand Down Expand Up @@ -86,76 +88,7 @@ public function getRelationFields()
}

/**
* Create the relations for the current model.
*
* @param \Illuminate\Database\Eloquent\Model $item The current CRUD model.
* @param array $data The form data.
*/
public function createRelations($item, $data)
{
$this->syncPivot($item, $data);
$this->createOneToOneRelations($item, $data);
}

/**
* Sync the declared many-to-many associations through the pivot field.
*
* @param \Illuminate\Database\Eloquent\Model $model The current CRUD model.
* @param array $input The form input.
*/
public function syncPivot($model, $input)
{
$fields_with_pivot = $this->getRelationFieldsWithPivot();

//remove fields that are not in the submitted form input
$fields_with_pivot = array_filter($fields_with_pivot, function ($item) use ($input) {
return Arr::has($input, $item['name']);
});

foreach ($fields_with_pivot as $key => $field) {
$values = $input[$field['name']] ?? [];

// if a JSON was passed instead of an array, turn it into an array
if (is_string($values)) {
$values = json_decode($values, true);
}

$relation_data = [];
if (isset($field['subfields'])) {
foreach ($values as $pivot_row) {
$relation_data[$pivot_row[$field['name']]] = Arr::except($pivot_row, $field['name']);
}
}

// if there is no relation data, and the values array is single dimensional we have
// an array of keys with no aditional pivot data. sync those.
if (empty($relation_data) && count($values) == count($values, COUNT_RECURSIVE)) {
$relation_data = array_values($values);
}

$model->{$field['name']}()->sync($relation_data);

if (isset($field['morph']) && $field['morph'] && isset($input[$field['name']])) {
$values = $input[$field['name']];
$model->{$field['name']}()->sync($values);
}
}
}

/**
* Create any existing one to one relations for the current model from the form data.
*
* @param \Illuminate\Database\Eloquent\Model $item The current CRUD model.
* @param array $input The form data.
*/
private function createOneToOneRelations($item, $input)
{
$relationDetails = $this->getRelationDetailsFromInput($input);
$this->createRelationsForItem($item, $relationDetails);
}

/**
* Create any existing one to one relations for the current model from the relation data.
* Create relations for the provided model.
*
* @param \Illuminate\Database\Eloquent\Model $item The current CRUD model.
* @param array $formattedRelations The form data.
Expand All @@ -170,30 +103,58 @@ private function createRelationsForItem($item, $formattedRelations)
if (! isset($relationDetails['model'])) {
continue;
}
$model = $relationDetails['model'];
$relation = $item->{$relationMethod}();
$relationType = $relationDetails['relation_type'];

switch ($relationType) {
case 'BelongsTo':
$modelInstance = $relationDetails['model']::find($relationDetails['values'])->first();
if ($modelInstance != null) {
$relation->associate($modelInstance)->save();
} else {
$relation->dissociate()->save();
}
break;
case 'HasOne':
case 'MorphOne':
$modelInstance = $this->createUpdateOrDeleteOneToOneRelation($relation, $relationMethod, $relationDetails);
break;
case 'HasMany':
case 'MorphMany':
$relationValues = $relationDetails['values'][$relationMethod];
// if relation values are null we can only attach, also we check if we sent
// - a single dimensional array: [1,2,3]
// - an array of arrays: [[1][2][3]]
// if is as single dimensional array we can only attach.
if ($relationValues === null || ! is_multidimensional_array($relationValues)) {
$this->attachManyRelation($item, $relation, $relationDetails, $relationValues);
} else {
$this->createManyEntries($item, $relation, $relationMethod, $relationDetails);
}
break;
case 'BelongsToMany':
case 'MorphToMany':
$values = $relationDetails['values'][$relationMethod] ?? [];
$values = is_string($values) ? json_decode($values, true) : $values;

$relationValues = [];

if (is_multidimensional_array($values)) {
foreach ($values as $value) {
$relationValues[$value[$relationMethod]] = Arr::except($value, $relationMethod);
}
}

if ($relation instanceof BelongsTo) {
$modelInstance = $model::find($relationDetails['values'])->first();
if ($modelInstance != null) {
$relation->associate($modelInstance)->save();
} else {
$relation->dissociate()->save();
}
} elseif ($relation instanceof HasOne || $relation instanceof MorphOne) {
$modelInstance = $this->createUpdateOrDeleteOneToOneRelation($relation, $relationMethod, $relationDetails);
} elseif ($relation instanceof HasMany || $relation instanceof MorphMany) {
$relation_values = $relationDetails['values'][$relationMethod];
// if relation values are null we can only attach, also we check if we sent
// - a single dimensional array: [1,2,3]
// - an array of arrays: [[1][2][3]]
// if is as single dimensional array we can only attach.
if ($relation_values === null || count($relation_values) == count($relation_values, COUNT_RECURSIVE)) {
$this->attachManyRelation($item, $relation, $relationDetails, $relation_values);
} else {
$this->createManyEntries($item, $relation, $relationMethod, $relationDetails);
}
// if there is no relation data, and the values array is single dimensional we have
// an array of keys with no aditional pivot data. sync those.
if (empty($relationValues)) {
$relationValues = array_values($values);
}

$item->{$relationMethod}()->sync($relationValues);
break;
}

if (isset($relationDetails['relations'])) {
$this->createRelationsForItem($modelInstance, ['relations' => $relationDetails['relations']]);
}
Expand Down Expand Up @@ -242,7 +203,7 @@ private function createUpdateOrDeleteOneToOneRelation($relation, $relationMethod
}

// Scenario C (when it's an array inside an array, because it's been added as one item inside a repeatable field)
if (gettype($relationMethodValue) == 'array' && count($relationMethodValue) != count($relationMethodValue, COUNT_RECURSIVE)) {
if (gettype($relationMethodValue) == 'array' && is_multidimensional_array($relationMethodValue)) {
return $relation->updateOrCreate([], current($relationMethodValue));
}
}
Expand Down Expand Up @@ -363,18 +324,18 @@ private function createManyEntries($entry, $relation, $relationMethod, $relation
*/
private function getRelationDetailsFromInput($input)
{
$relationFields = $this->getRelationFieldsWithoutPivot();

//remove fields that are not in the submitted form input
$relationFields = array_filter($relationFields, function ($item) use ($input) {
return Arr::has($input, $item['name']);
});
$relationFields = $this->getRelationFields();

// exclude the already attached belongs to relations in the main entry but include nested belongs to.
$relationFields = Arr::where($relationFields, function ($field, $key) {
return $field['relation_type'] !== 'BelongsTo' || ($field['relation_type'] === 'BelongsTo' && Str::contains($field['name'], '.'));
});

//remove fields that are not in the submitted form input
$relationFields = array_filter($relationFields, function ($field) use ($input) {
return Arr::has($input, $field['name']);
});

$relationDetails = [];
foreach ($relationFields as $field) {
// we split the entity into relations, eg: user.accountDetails.address
Expand All @@ -391,6 +352,7 @@ private function getRelationDetailsFromInput($input)
$fieldDetails['parent'] = $fieldDetails['parent'] ?? $this->getRelationModel($field['name'], -1);
$fieldDetails['entity'] = $fieldDetails['entity'] ?? $field['entity'];
$fieldDetails['attribute'] = $fieldDetails['attribute'] ?? $field['attribute'];
$fieldDetails['relation_type'] = $fieldDetails['relation_type'] ?? $field['relation_type'];
$fieldDetails['values'][$attributeName] = Arr::get($input, $field['name']);

if (isset($field['fallback_id'])) {
Expand All @@ -405,4 +367,24 @@ private function getRelationDetailsFromInput($input)

return $relationDetails;
}

/**
* Returns an array of field names, after we keep only what's before the dots.
* Field names that use dot notation are considered as being "grouped fields"
* eg: address.city, address.postal_code
* And for all those fields, this function will only return one field name (what is before the dot).
*
* @param array $fields - the fields from where the name would be returned.
* @return array
*/
private function getFieldNamesBeforeFirstDot($fields)
{
$field_names_array = [];

foreach ($fields as $field) {
$field_names_array[] = Str::before($field['name'], '.');
}

return array_unique($field_names_array);
}
}
83 changes: 70 additions & 13 deletions src/app/Library/CrudPanel/Traits/Relationships.php
Original file line number Diff line number Diff line change
Expand Up @@ -74,19 +74,21 @@ public function getOnlyRelationEntity($field)
* relations - it will NOT look through relationships of relationships.
*
* @param string|array $relation_types Eloquent relation class or array of Eloquent relation classes. Eg: BelongsTo
* @param bool $nested Should nested fields be included
* @return array The fields with corresponding relation types.
*/
public function getFieldsWithRelationType($relation_types): array
public function getFieldsWithRelationType($relation_types, $nested = false): array
{
$relation_types = (array) $relation_types;

return collect($this->fields())
->where('model')
return collect($this->getCleanStateFields())
->whereIn('relation_type', $relation_types)
->filter(function ($item) {
$related_model = get_class($this->model->{Str::before($item['entity'], '.')}()->getRelated());
->filter(function ($item) use ($nested) {
if ($nested) {
return true;
}

return Str::contains($item['entity'], '.') && $item['model'] !== $related_model ? false : true;
return Str::contains($item['entity'], '.') ? false : true;
})
->toArray();
}
Expand Down Expand Up @@ -117,19 +119,53 @@ public function parseRelationFieldNamesFromHtml($fields)
return $fields;
}

protected function changeBelongsToNamesFromRelationshipToForeignKey($data)
/**
* Gets the relation fields that DON'T contain the provided relations.
*
* @param string|array $relations - the relations to exclude
* @param bool $include_nested - if the nested relations of the same relations should be excluded too.
*/
private function getRelationFieldsWithoutRelationType($relations, $include_nested = false)
{
$belongs_to_fields = $this->getFieldsWithRelationType('BelongsTo');
if (! is_array($relations)) {
$relations = [$relations];
}

$fields = $this->getRelationFields();

foreach ($relations as $relation) {
$fields = array_filter($fields, function ($field) use ($relation, $include_nested) {
if ($include_nested) {
return $field['relation_type'] !== $relation || ($field['relation_type'] === $relation && Str::contains($field['name'], '.'));
}

return $field['relation_type'] !== $relation;
});
}

return $fields;
}

/**
* Changes the BelongsTo names in the input from request to allways
* have the foreign_key instead of the relation name.
* It only changes main relations not nested.
*
* eg: user -> user_id
*/
private function changeBelongsToNamesFromRelationshipToForeignKey($input)
{
$belongs_to_fields = $this->getFieldsWithRelationType('BelongsTo');
foreach ($belongs_to_fields as $relation_field) {
$relation = $this->getRelationInstance($relation_field);
if (Arr::has($data, $relation->getRelationName())) {
$data[$relation->getForeignKeyName()] = Arr::get($data, $relation->getRelationName());
unset($data[$relation->getRelationName()]);
$name_for_sub = $this->getOverwrittenNameForBelongsTo($relation_field);

if (Arr::has($input, $relation_field['name']) && $relation_field['name'] !== $name_for_sub) {
Arr::set($input, $name_for_sub, Arr::get($input, $relation_field['name']));
Arr::forget($input, $relation_field['name']);
}
}

return $data;
return $input;
}

/**
Expand Down Expand Up @@ -201,4 +237,25 @@ public function getRelationFieldsWithPivot()
return isset($value['pivot']) && $value['pivot'];
});
}

/**
* Return the name for the BelongTo relation making sure it always has the foreign_key instead of relationName
* eg: user - user_id OR address.country - address.country_id.
*
* @param array $field The field we want to get the name from
*/
private function getOverwrittenNameForBelongsTo($field)
{
$relation = $this->getRelationInstance($field);

if (Str::afterLast($field['name'], '.') === $relation->getRelationName()) {
if (Str::contains($field['name'], '.')) {
return Str::beforeLast($field['name'], '.').'.'.$relation->getForeignKeyName();
}

return $relation->getForeignKeyName();
}

return $field['name'];
}
}
Loading