Proposal for trigger-based decorators
- Dominant language
- Python
- Stars
- 415
- Forks
- 50
- PR merge metrics
- No merged PRs in 30d
Description
## 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.
```py
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.
```py
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.
```py
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.
```py
# 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.
```py
@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.
```py
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 a `QueryManager` which 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 `QueryManager` is 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 `ACTION` trigger type would presume a declaration has been made, such as creating a new property.
- `MUTATION` assumes that data that has already existed has been modified in some form or variation, such as `DROP`.
Contributor guide
No contributing guide indexed for this repository
Assessment
This issue has not been assessed yet.