graphiti-api/spraypaint.js

[Maybe bug?] Empty array does not show up in nested write

Opened this issue · 2 comments

E.g. a Company that has many Employee

company.employees = [];
company.save({ with: 'employees' });

This is the payload which doesn't include the relationships field:

{ "data": { "type": "companies" } }

It seems to be because of this line explicitly setting it to null
https://github.com/graphiti-api/spraypaint.js/blob/master/src/util/write-payload.ts#L115

I just wonder this is intended? (JSON:API spec allows replacement of the entire array: https://jsonapi.org/format/#crud-updating-resource-relationships)

Hey @steventhan yes this is by design. We want to default the relationship to [], so it introduces a somewhat likely scenario where you don't sideload anything (or sideload then remove) and accidentally send [] to the server wiping away all your data. We want to keep our dirty-tracking system without causing these major accidents.

We do have other ways to delete/disassociate relationships though https://www.graphiti.dev/guides/concepts/resources#sideposting

In theory we could support something like this with a special flag or something, but I tend to think it's not worth the effort as a less-common use case (with a big possible downside). If you have this scenario, consider a one-off separate resource for the special case, or maybe a magic attribute flag (ie company.deleteEmployees = true).

I had the same problem and made a generic solution.
You can define the relations to check and detach when empty directly when loading the model:

Company
      .includes(['employees'])
      .find(123)
      .then(data => data.data.detachWhenEmpty(['employees']))

All you have to do is to extend your Base class with this class:

@Model()
export default class BaseModel extends SpraypaintBaseDetachRelationsWhenEmpty {
  static baseUrl = process.env.API_URL
  static apiNamespace = '/v1'

 // ...
}
import { isArray, isEmpty } from 'lodash';
import { Attr, SpraypaintBase } from 'spraypaint';
import { SaveOptions } from 'spraypaint/lib-esm/model';

export default class SpraypaintBaseDetachRelationsWhenEmpty extends SpraypaintBase {

  /**
   * hack to allow detaching all relations since spraypaint does not send an empty array
   * @see CommonRessourceRequest.php -> after()
   * https://github.com/graphiti-api/spraypaint.js/issues/81
   */

  @Attr() private detachRelationsByName: string[] = []

  detachRelationsWhenEmptyByName: string[] = []

  detachWhenEmpty(names: string|string[]): this {
    if(isArray(names)) {
      for(const name of names) {
        this.detachRelationsWhenEmptyByName.push(name)
      }
    } else {
      this.detachRelationsWhenEmptyByName.push(names)
    }
    return this
  }

  private checkDetachRelationsWhenEmpty() {
    for(const relationName of this.detachRelationsWhenEmptyByName) {
      // eslint-disable-next-line @typescript-eslint/ban-ts-comment
      // @ts-ignore
      const relation = this[relationName]
      if(isEmpty(relation)) {
        if(this.detachRelationsByName === null) {
          this.detachRelationsByName = []
        }
        this.detachRelationsByName.push(relationName)
      }
    }
  }

  save<I extends SpraypaintBase>(options?: SaveOptions<I>): Promise<boolean> {
    this.checkDetachRelationsWhenEmpty()
    return super.save(options);
  }

}

If you are using Laravel JSON:API in your backend, you can check the field dynamically, too:

CompanySchema.php

class CompanySchema extends CommonSchema {
    public function fields(): array
    {
        return self::withDefaults([
              // fields for company
        ]);
    }
}

CommonSchema.php

abstract class CommonSchema extends Schema {
    public static function withDefaults(array $fields): array {
        return array_merge($fields, [
            ArrayList::make('detachRelationsByName') // @see CommonRessourceRequest::after()
        ]);
    }
}

CompanyRequest.php

class CompanyRequest extends CommonRessourceRequest {
   // ...
}

CommonRessourceRequest.php

use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Validation\Validator;
use LaravelJsonApi\Laravel\Http\Requests\ResourceRequest;

abstract class CommonRessourceRequest extends ResourceRequest {

    /**
     * https://laravel.com/docs/10.x/validation#performing-additional-validation-on-form-requests
     *
     * @return array
     */
    public function after(): array {
        return [
            /**
             * hack to allow detaching all relations since spraypaint does not send an empty array
             * @see SpraypaintBaseDetachRelationsWhenEmpty.ts
             * https://github.com/graphiti-api/spraypaint.js/issues/81
             */
            function (Validator $validator) {
                $detachRelationsByName = request()?->input('data.attributes.detachRelationsByName');
                $model = $this->model();
                if(isset($detachRelationsByName, $model)) {
                    foreach ($detachRelationsByName as $relationName) {
                        if($model->isRelation($relationName)) {
                            $relation = $model->{$relationName}();
                            $data = $validator->getData();
                            unset($data[$relationName]); // prevent existing values being merged 
                            $validator->setData($data);
                            if($relation instanceof BelongsToMany) {
                                $relation->detach();
                            }
                            elseif($relation instanceof BelongsTo) {
                                $relation->disassociate();
                            }
                           // add other relation types if needed
                        }
                    }
                }
            }

        ];
    }
}