Skip to content

Conversation

a8m
Copy link
Member

@a8m a8m commented Mar 6, 2020

#371

This is a WIP and it should be ignored for now.
There are a few things that need to be added before merging it, and additional work that can be added in separate PRs:

  • Documentation.
  • Align Gremlin dialect with the new mutation interface.
  • Create edge constant for interacting with the mutation interface.
  • Load hooks from schema - There's one open question here - if the schema imports the
    generated ent package, we can have a circular dependency situation (because ent.Client needs to register its schema.Hooks, but can't import them). So, we need to decide on one solution here.
    1. Generate a new package named entschema, that will register the schema hooks in the generated ent package. Then, users will need to empty import it as follows:
      import (
          "<project>/ent"
          _ "<project>/ent/entschema"
      )
      // ... 
      Thoughts: maybe entruntime or just runtime instead of entschema?
    2. We'll create a new entclient package, and request users to use it in order to create a new
      ent.Client.
      import (
          "<project>/entclient"
      )
      
      func main() {
          client, err := entclient.Open("...")
          // ...
      }
    The 2 new packages will read the hooks from the schema, and register them in the ent package. We keep backwards compatibility, and generate those packages only if the user uses hooks in the schema. No changes are needed, if a user registers hooks on runtime (on client creation).

Next steps:

  • Add package hook (also WIP) for auto type-assertion, or operation filtering. For example:
    hk := func(next ent.Mutator) ent.Mutator {
        return hook.CardCreate(func(ctx context.Context, m *ent.CardMutation) (ent.Value, error) {
            // boring.
            return next.Mutate(ctx, m)
        })
    }
  • Add the option for loading mutated nodes (or getting old values of fields).

@facebook-github-bot facebook-github-bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Mar 6, 2020
@a8m
Copy link
Member Author

a8m commented Mar 6, 2020

The usage of hooks in the entity schema looks as follows:

type User struct {
    ent.Schema
}

func (User) Hooks() []ent.Hook {
    return []ent.Hook{
        func(next ent.Mutator) ent.Mutator {
            return ent.MutateFunc(func(ctx context.Context, m ent.Mutation) (ent.Value, error) {
                // m's concrete type is: *ent.UserMutation. 
                return next.Mutate(ctx, m)
            })
        },
        // More hooks.
        // ...
    }
}

In runtime, you can add global hooks as follows:

client.Use(func(next ent.Mutator) ent.Mutator {
    return ent.MutateFunc(func(ctx context.Context, m ent.Mutation) (ent.Value, error) {
        fmt.Println("mutation start")
        defer fmt.Println("mutation end")
        return next.Mutate(ctx, m)
    })
})

Or, to a specific client:

client.User.Use(func(next ent.Mutator) ent.Mutator {
    return ent.MutateFunc(func(ctx context.Context, m ent.Mutation) (ent.Value, error) {
        fmt.Println("user mutation start")
        defer fmt.Println("user mutation end")
        return next.Mutate(ctx, m)
    })
})

The ent.Mutation definition is:

type Value interface{}

type Mutation interface {
    // Op returns the operation name generated by entc.
    Op() string
    // Type returns the schema type for this mutation.
    Type() string

    // Fields returns all fields that were changed during
    // this mutation. Note that, in order to get all numeric
    // fields that were in/decremented, call AddedFields().
    Fields() []string
    // Field returns the value of a field with the given name.
    // The second boolean value indicates that this field was
    // not set, or was not define in the schema.
    Field(name string) (Value, bool)
    // SetField sets the value for the given name. It returns an
    // error if the field is not defined in the schema, or if the
    // type mismatch the field type.
    SetField(name string, value Value) error

    // AddedFields returns all numeric fields that were incremented
    // or decremented during this mutation.
    AddedFields() []string
    // AddedField returns the numeric value that was in/decremented
    // from a field with the given name. The second value indicates
    // that this field was not set, or was not define in the schema.
    AddedField(name string) (Value, bool)
    // AddField adds the value for the given name. It returns an
    // error if the field is not defined in the schema, or if the
    // type mismatch the field type.
    AddField(name string, value Value) error

    // ClearedFields returns all nullable fields that were cleared
    // during this mutation.
    ClearedFields() []string
    // FieldCleared returns a boolean indicates if this field was
    // cleared in this mutation.
    FieldCleared(name string) bool
    // ClearField clears the value for the given name. It returns an
    // error if the field is not defined in the schema.
    ClearField(name string) error

    // ResetField resets all changes in the mutation regarding the
    // given field name. It returns an error if the field is not
    // defined in the schema.
    ResetField(name string) error

    // AddedEdges returns all edge names that were set/added in this
    // mutation.
    AddedEdges() []string
    // AddedIDs returns all ids (to other nodes) that were added for
    // the given edge name.
    AddedIDs(name string) []Value

    // RemovedEdges returns all edge names that were removed in this
    // mutation.
    RemovedEdges() []string
    // RemovedIDs returns all ids (to other nodes) that were removed for
    // the given edge name.
    RemovedIDs(name string) []Value

    // ClearedEdges returns all edge names that were cleared in this
    // mutation.
    ClearedEdges() []string
    // EdgeCleared returns a boolean indicates if this edge was
    // cleared in this mutation.
    EdgeCleared(name string) bool
    // ClearEdge clears the value for the given name. It returns an
    // error if the edge name is not defined in the schema.
    ClearEdge(name string) error

    // ResetEdge resets all changes in the mutation regarding the
    // given edge name. It returns an error if the edge is not
    // defined in the schema.
    ResetEdge(name string) error
}

The concrete mutation type gives a type safe API for each schema. For example:

// ID returns the id value in the mutation. Note that, the id
// is available only if it was provided to the builder.
func (m *CardMutation) ID() (id int, exist bool)

// SetBoring sets the boring field.
func (m *CardMutation) SetBoring(t time.Time)

// Boring returns the boring value in the mutation.
func (m *CardMutation) Boring() (r time.Time, exist bool)

// ResetBoring reset all changes of the boring field.
func (m *CardMutation) ResetBoring()

// SetNumber sets the number field.
func (m *CardMutation) SetNumber(s string)

// Number returns the number value in the mutation.
func (m *CardMutation) Number() (r string, exist bool) 

// ResetNumber reset all changes of the number field.
func (m *CardMutation) ResetNumber()

// SetName sets the name field.
func (m *CardMutation) SetName(s string)

// Name returns the name value in the mutation.
func (m *CardMutation) Name() (r string, exist bool) 

// ClearName clears the value of name.
func (m *CardMutation) ClearName()

// NameCleared returns if the field name was cleared in this mutation.
func (m *CardMutation) NameCleared() bool

// ResetName reset all changes of the name field.
func (m *CardMutation) ResetName() 

// AddFriendIDs adds the friends edge to Card by ids.
func (m *CardMutation) AddFriendIDs(ids ...int)

// RemoveFriendIDs removes the friends edge to Card by ids.
func (m *CardMutation) RemoveFriendIDs(ids ...int) 

// RemovedFriends returns the removed ids of friends.
func (m *CardMutation) RemovedFriendsIDs() (ids []int)

// FriendsIDs returns the friends ids in the mutation.
func (m *CardMutation) FriendsIDs() (ids []int)

// ResetFriends reset all changes of the friends edge.
func (m *CardMutation) ResetFriends()

// SetBestFriendID sets the best_friend edge to Card by id.
func (m *CardMutation) SetBestFriendID(id int)

// ClearBestFriend clears the best_friend edge to Card.
func (m *CardMutation) ClearBestFriend() 

// BestFriendCleared returns if the edge best_friend was cleared.
func (m *CardMutation) BestFriendCleared() bool 

// BestFriendIDs returns the best_friend ids in the mutation.
func (m *CardMutation) BestFriendIDs() (ids []int)

// ResetBestFriend reset all changes of the best_friend edge.
func (m *CardMutation) ResetBestFriend()

When dealing with generic mutations, there are 2 options to read and mutate fields/edges
of multiple types. One, use the Mutation methods:

func M(ctx context.Context, m ent.Mutation) (ent.Value, error) {
    v, ok := m.Field(user.FieldName) // or, m.Field("name")
    if ok {
        // ...
    }
    // An error is returned when field does not exist in the
    // schema, or the value type mismatch the field type.
    if err := m.SetField(user.FieldName, "boring"); err != nil {
        return nil, err
    }
    // ...
}

Use, type assertion:

func M(ctx context.Context, m ent.Mutation) (ent.Value, error) {
    type NameSetter interface {
        Name() (string, bool)
        SetName(string)
    }
    ns, ok := m.(NameSetter)
    if !ok {
        // ignore.
    }
    name, ok := ns.Name()
    if ok {
         // ...
    }
    ns.SetName("boring")
    // ...
}

WIP: An hook package for auto type-assertion:

client.Card.Use(func(next ent.Mutator) ent.Mutator {
    return hook.UserFunc(func(ctx context.Context, m *ent.UserMutation) (ent.Value, error) {
        // Do something.
        return next.Mutate(ctx, m)
    })
})

TODO for next PRs: Add an API for getting the ent.Client/ent.Tx and loading old values.

@a8m a8m mentioned this pull request Mar 6, 2020
@a8m a8m force-pushed the hooks branch 3 times, most recently from 23823d3 to fd1879c Compare March 6, 2020 21:02
@a8m
Copy link
Member Author

a8m commented Mar 6, 2020

I thought at the start to generate the schema-stitching logic under the user's schema package, but this made the development workflow pretty bad - renaming/deleting schemas require deleting manually the generated file, and build flags didn't help here, because this requires from the user to pass them to all other Go commands (e.g. go generate).

@a8m a8m force-pushed the hooks branch 6 times, most recently from ba577d9 to b61f1c1 Compare March 9, 2020 08:20
@KCarretto
Copy link

Per our previous discussion:

The first proposal seems the most flexible, and with codegen (i.e. generating the user.Mutator interface) it's pretty straightforward to use.

One suggestion for a minor improvement would be to create an interface for each mutation operation (i.e. DeletionMutator) in addition to the generic Mutator. This guarantees that the mutator is only called for the expected operation, removing the operation check boilerplate.

Here's an adaptation of your example with this suggestion:

func DeletePets(next user.Mutator) user.DeletionMutator {
return user.DeletionMutatorFunc(func (ctx context.Context, mv user.Mutation) (user.Result, error) {
// No need to check operation, mutator only called for Delete
// Delete all user pets.
pets, err := mv.QueryPets()
if err != nil {
return nil, err
}
// Execute wrapped middleware.
res, err := next.Mutate(ctx, mv)
if err != nil {
return nil, err
}
// Delete all pets.
// ...
return res, nil
})
}

Another small benefit is that if the mutation views / results are ever split into separate types, the mutator signature could easily reflect that instead of forcing each mutator to perform a type assertion.

For example:

type DeletionMutatorFunc func(context.Context, DeletionMutation) (DeletionResult, error)

Regardless, excited for this, looks awesome!

@a8m
Copy link
Member Author

a8m commented Mar 15, 2020

Hey @KCarretto, and thanks for the feedback!

We started with a simple implementation (the ent.Mutation), and we're going to develop the privacy and the cascading-deletion features on top of it. It's probably going to change, since we'll learn a lot by playing with this.

We don't have a separate interface per operation right now, but we have a hook selector for achieving a similar capability at the moment. For example:

hook.On(LoggingHook(), ent.Update|ent.Delete)

Thanks for your feedback!

@a8m
Copy link
Member Author

a8m commented Mar 15, 2020

Documentation will land tomorrow (still on progress).

@a8m a8m merged commit 7988d30 into master Mar 15, 2020
@a8m a8m deleted the hooks branch March 16, 2020 07:55
@a8m a8m mentioned this pull request Mar 22, 2020
27 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants