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

Proposal for trigger-based decorators #361

Open
i0bs opened this issue Aug 18, 2022 · 1 comment
Open

Proposal for trigger-based decorators #361

i0bs opened this issue Aug 18, 2022 · 1 comment

Comments

@i0bs
Copy link

i0bs commented Aug 18, 2022

Introduction

Relevant to edgedb/edgedb#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 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.
@1st1
Copy link
Member

1st1 commented Aug 18, 2022

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants