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

updateOrCreate functionality #2214

Open
eNzyOfficial opened this issue Oct 20, 2022 · 6 comments
Open

updateOrCreate functionality #2214

eNzyOfficial opened this issue Oct 20, 2022 · 6 comments
Labels
discussion Requires input from multiple people

Comments

@eNzyOfficial
Copy link
Contributor

eNzyOfficial commented Oct 20, 2022

Not sure if this is useful for core lighthouse but I can create a PR if it's something of interest

What problem does this feature proposal attempt to solve?

Similar to the eloquent method updateOrCreate

Which possible solutions should be considered?

This is what I currently have in our project, it works in the same way as updateOrCreate with the added feature of being able to restore softDeleted models as well

Usecase for the softdeleted stuff:
We have a proposal which is basically an intermediary table for client/candidate that also uses softdeletes (so it can be restored and we can keep all previous activity around that proposal should we need to look at it) this is restrained to unique([client_id,candidate_id]) so trying to create another row with the same will throw an error, rather than checking if a row exists, and if its deleted, then changing it across multiple api calls, it's easier just to do it through a single directive.

Open to alternative solutions, I kinda threw this together via the other directives/arguments for @create and @update

UpdateOrCreateDirective.php (the actual directive)

class UpdateOrCreateDirective extends MutationExecutorDirective
{
    public static function definition(): string
    {
        return /** @lang GraphQL */ <<<'GRAPHQL'
"""
Attempts to update a record, if not record is found it will create one instead
"""
directive @updateOrCreate(
  """
    A list of columns to search for from the list of args
  """
  columns: [String!]!
  
  """
    Whether a soft deleted row should be restored and updated
  """
  canRestore: Boolean = false
) on FIELD_DEFINITION | ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION
GRAPHQL;
    }

    protected function makeExecutionFunction(?Relation $parentRelation = null): callable
    {
        return new UpdateOrCreateModel(
            new SaveModel($parentRelation),
            $this->columnsArgValue(),
            $this->canRestoreArgValue()
        );
    }

    public function columnsArgValue(): array
    {
        return $this->directiveArgValue('columns');
    }

    public function canRestoreArgValue(): bool
    {
        return $this->directiveArgValue('canRestore', false);
    }
}

UpdateOrCreateModel.php (ArgResolver)

class UpdateOrCreateModel implements ArgResolver
{
    /**
     * @var callable|ArgResolver
     */
    protected $previous;

    /**
     * @var array
     */
    protected $columns;

    /**
     * @var bool
     */
    protected $canRestore;

    /**
     * @param callable|ArgResolver $previous
     */
    public function __construct(callable $previous, array $columns, bool $canRestore = false)
    {
        $this->previous = $previous;
        $this->columns = $columns;
        $this->canRestore = $canRestore;
    }

    /**
     * @param Model $model
     * @param ArgumentSet $args
     */
    public function __invoke($model, $args)
    {
        /** @var ArgumentSet $belongsTo */
        [$belongsTo, $remaining] = ArgPartitioner::relationMethods(
            $args,
            $model,
            BelongsTo::class
        );

        foreach ($belongsTo->arguments as $relationName => $nestedOperations) {
            /** @var BelongsTo $belongsTo */
            $belongsTo = $model->{$relationName}();
            $belongsToResolver = new ResolveNested(new NestedBelongsTo($belongsTo));
            $belongsToResolver($model, $nestedOperations->value);
        }

        $shouldRestoreSoftDeletes = $this->canRestore && in_array(SoftDeletes::class, class_uses($model), true);

        $existingModel = $model
            ->newQuery()
            ->when($shouldRestoreSoftDeletes, fn($builder) => $builder->withoutGlobalScope(SoftDeletingScope::class))
            ->where(
                array_intersect_key([
                    ...$remaining->toArray(),
                    ...$model->getAttributes()
                ], array_flip($this->columns))
            )
            ->first();

        if ($shouldRestoreSoftDeletes) {
            $args->addValue('deleted_at', null);
        }

        return ($this->previous)($existingModel ?? $model, $args);
    }
}
@spawnia
Copy link
Collaborator

spawnia commented Oct 20, 2022

@spawnia spawnia added the question Request for support or clarification label Oct 20, 2022
@spawnia spawnia closed this as completed Oct 20, 2022
@eNzyOfficial
Copy link
Contributor Author

Sorry to reopen but I think you may have overlooked how this works. It works differently to @upsert, which requires an ID to be passed. This works similar to eloquents updateOrCreate

Using the directive, you specify to columns to run the update portion of the check against, in this example candidate is a relation but gets resolved down to candidate_id the same as in the database. It will check the DB for matching candidate_id and client_id if it finds it, it will update the row with the remaining args passed, if not it will create a model in the same way as @create does

createProposal(input: ProposalCreateInput! @spread): Proposal @updateOrCreate(columns: ["candidate_id", "client_id"] canRestore: true)
mutation CreateProposal {
  createProposal(input: {
    description: "test"
    candidate: {
      connect: 1
    }
    client: {
      connect: 1
    }
  }) {
    id
  }
}

@spawnia spawnia reopened this Oct 21, 2022
@spawnia
Copy link
Collaborator

spawnia commented Oct 21, 2022

I see how your proposal is mechanically distinct from the existing directives, thank you for elaborating and providing an example.

Given the semantic similarity to @upsert, I could see the implementation being a directive argument for it - how about identifyingColumns?

createProposal(input: ProposalCreateInput! @spread): Proposal!
    @upsert(identifyingColumns: ["candidate_id", "client_id"])

The canRestore functionality would be a distinct feature which I could see being useful for both @update and @upsert.

@spawnia spawnia added discussion Requires input from multiple people and removed question Request for support or clarification labels Oct 21, 2022
@spawnia spawnia changed the title @updateOrCreate directive updateOrCreate functionality Oct 21, 2022
@GavG
Copy link

GavG commented Jul 26, 2023

I see how your proposal is mechanically distinct from the existing directives, thank you for elaborating and providing an example.

Given the semantic similarity to @upsert, I could see the implementation being a directive argument for it - how about identifyingColumns?

createProposal(input: ProposalCreateInput! @spread): Proposal!
    @upsert(identifyingColumns: ["candidate_id", "client_id"])

The canRestore functionality would be a distinct feature which I could see being useful for both @update and @upsert.

Any chance you could provide some pointers on how to go about adding directive arguments?

I've done a hacky version of upserting by custom fields on a fork to achieve the this functionality, but it would be great to get this into the main repo. Didn't manage to figure out directive args last time I tried. Happy to give it another stab though.

@spawnia
Copy link
Collaborator

spawnia commented Jul 27, 2023

@upsert is particularly tricky since most of its logic is abstracted in MutationExecutorDirective. For a simple example of how to add a directive argument, you can look at existing pull requests like these: #2416 #2138.

@GavG
Copy link

GavG commented Jul 28, 2023

@spawnia Thanks for the pointers. I've made a brief start on this: #2426

I've managed to get a very basic version of the identifyingColumns functionality working for single-level inputs only. I haven't tackled nested relationships yet or more complex upsert logic, as I think this will need more thought.

@eNzyOfficial looks to have relationships working in his implementation, but I was hesitant to copy that into upsert as I suspected the SaveModel and ArgPartitioner classes may already encapsulate some of that logic.

Revisiting my own use case for this, I found that I needed more control for upsert queries, e.g. multiple conditional where, whereHas and orWhereHas clauses. I'll have a think about how best this could be modelled. If anyone else has a good idea feel free to chip in :) It may be the case that custom directives are the best option for such complex mutations.

My real world use case

We have an API where users can create Quotes, Quotes have Locations. A Location must belong to a Postcode, can belong to an Address and a CLI (phone number).

Users can create Locations with these relationships via a mutation:

input QuoteRequestLineInput {
   ...
    quoteRequestLocationBEnd: QuoteRequestLocationBEndBelongsTo!
}

input QuoteRequestLocationBEndBelongsTo {
    upsert: QuoteRequestLocationInput
}

input QuoteRequestLocationInput {
    postcode: PostcodeBelongsTo!
    cli: CliBelongsTo
    address: AddressBelongsTo
}

When a user submits a Location upsert mutation, we would like to update any existing Locations that have the same Postcode and either the same Address or the same CLI if provided.

Users don't know the IDs of Addresses or CLIs, so we try to match details using a base query that we crudely resolve from the UpsertModel:

QuoteRequestLocation::whereHas(
    'postcode',
    static fn ($q) => $q->where('postcode', $postcode)
)
->when(
    $cli || $address,
    static fn ($q) => $q->where(
        static fn ($q) => $q->whereHas(
            'cli',
            static fn ($q) => $q->where('cli', $cli)
        )
            ->orWhereHas(
                'address',
                static fn ($q) => $q->where([
                    ['thoroughfare', '=', Arr::get($address, 'thoroughfare')],
                    ['building_number', '=', Arr::get($address, 'building_number')],
                    ['sub_building_number', '=', Arr::get($address, 'sub_building_number', 0)],
                ])
            )
    )
);

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
discussion Requires input from multiple people
Projects
None yet
Development

No branches or pull requests

3 participants