Proposal for trigger-based decorators
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:
- Numerous types or groups share the same field name, and want to create a general trigger for them.
- The developer wants to log or trace back supplied arguments for a query.
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:
- Having
client.query()return aQueryManagerwhich essentially acts as a class for holding decorators and any necessary information. - "Wrapping" the decorator of the query manager class to work inside of classes, usually through
functools.wraps(). - Potential breaking changes would be induced by how
QueryManageris structured to contain data.
Additionally, this proposal implicitly brings about some general limitations to how you can create a handler for triggers:
- A trigger can only be set on an executed
query()call, meaning that you can't write any "listeners" or watchers. I would love to do this, but I believe it would cause confusion, and having it associated to a made query makes it explicitly clear on what's being triggered on what condition. - Triggers only assume that something happens to a field:
- An
ACTIONtrigger type would presume a declaration has been made, such as creating a new property. -
MUTATIONassumes that data that has already existed has been modified in some form or variation, such asDROP.
- An
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.