edgedb / edgedb-python

The official Python client library for EdgeDB
https://edgedb.com
Apache License 2.0
368 stars 44 forks source link

Proposal for trigger-based decorators #361

Open i0bs opened 2 years ago

i0bs commented 2 years ago

Introduction

Relevant to https://github.com/edgedb/edgedb/issues/4272 , having decorators for handling mutation and action triggers would be very beneficial for developers writing client-facing solutions with EdgeDB.

These are only my thoughts and ideas on introducing a more DX-friendly approach to writing trigger handlers for the client library. I'm more than happy to receive criticism or feedback towards this! 👋🏼

Motive

Decorators are an easy way for developers to overly simplify tasks, specifically handling events. In Discord's jargon we could consider these as "event handlers," although for EdgeDB this is much different. The premise is that a decorator can be used to "hook" a typing.Callable signature, (a function) to be called when a trigger has occurred within EdgeDB.

Design

There can be numerous types of triggers. So far, I am only aware of action and mutation triggers. Because we want the developer to have the ability to differentiate between the two, we can introduce a TriggerType enumerable to better represent these and make it clear for the client-facing code what trigger you want.

import enum

class TriggerType(enum.IntEnum):
    ACTION = 1
    MUTATION = 2
    ... # future types of triggers you wish to associate.

The trigger itself has to be registered as a decorator. In my example, I only have a mockup for an asynchronous/non-blocking solution which takes in typing.Coroutine. As this client library allows blocking calls as well, I don't have any ideas for how I'd do it that way.

def trigger(
    self,
    coro: typing.Coroutine,
    type: typing.Union[int, TriggerType]
    fields: typing.Union[str, typing.List[str]]
) -> typing.Callable[..., typing.Any]:
    def decor(coro: typing.Coroutine):
        ... # black magic and sorcery is done here. cast your spells!
    return decor

Usage

The usage of these decorators would be very simple: you give in one argument as a single field name, or a list of field names you want to trigger the callable off of.

First, we would need to establish our client and make a query.

import edgedb
import logging

logger = logging.getLogger(__file__)

client = edgedb.create_client()
query = client.query("""CREATE TYPE test {
    CREATE REQUIRED PROPERTY foo -> 
        std::str;
};""")

We can then use query here to associate a trigger via. a decorator.

# Note that the kwargs shown are not required, it just helps clarify what is being inputted.
@query.trigger(type=edgedb.TriggerType.ACTION, fields="field_name")

The problem with this proposal is that query would need to have a manager class like QueryManager in order to use decorators like this. One way we could make this work is by making the __repr__ magic of the manager return the result of our query call. (non-breaking) The other solution, which would be breaking, would be to alienate its return as query.content.

After that, you can place underneath an asynchronous task or coroutine.

@query.trigger(type=edgedb.TriggerType.ACTION, fields="field_name")
async def callback_response(ctx: edgedb.QueryContext) -> None:
    logger.debug("field_name triggered me.")

Here, you notice that we require 1 positional argument in the coroutine, ctx. This represents the context of our query, which we can provide to the developer if they so benefit from this. This may be particularly useful in these situations:

Class-bound triggers

Developers may also want to run their triggers inside of classes for organisation reasons. This should be possible so as long as the client is being supplied somehow. Note that with classes, the drawback is making use of the __call__ magic. The only decent method I know for this is by subclassing from another class that already inherits a client.

We will also have to have a way to wrap the QueryManager's decorator.

class MyClass(edgedb.TriggerClass):
    def __init__(self, client):
        super().__init__(client)

    @edgedb.class_trigger(type=edgedb.TriggerType.MUTATION, fields=["foo", "bar"])
    async def class_callback(self, ctx: edgedb.QueryContext) -> None:
        logger.debug("Numerous fields triggered me within the class.")

Caveats

With the introduction of decorators, there are admittedly a lot of things that would have to change for this proposal to work. These can be summed up as:

Additionally, this proposal implicitly brings about some general limitations to how you can create a handler for triggers:

1st1 commented 2 years ago

I think we should instead adopt the JS event receiver pattern or straight async for event in client.listen_for(...) syntax.

That said, the way we expose this in Python is a relatively minor design aspect, we first need to design the EdgeQL/ESDL parts as they will affect the design of everything else.

Lastly, I don't expect us to be able to listen on triggers. Triggers will be able to emit events (a separate mechanism), and we'll be listening for those.