Interceptors
Interceptors are execution middleware for various types of Ent queries. Contrary to hooks, interceptors are applied on the read-path and implemented as interfaces, allows them to intercept and modify the query at different stages, providing more fine-grained control over queries’ behavior. For example, see the Traverser interface below.
Defining an Interceptor
To define an Interceptor
, users can declare a struct that implements the Intercept
method or use the predefined ent.InterceptFunc
adapter.
ent.InterceptFunc(func(next ent.Querier) ent.Querier {
return ent.QuerierFunc(func(ctx context.Context, query ent.Query) (ent.Value, error) {
// Do something before the query execution.
value, err := next.Query(ctx, query)
// Do something after the query execution.
return value, err
})
})
In the example above, the ent.Query
represents a generated query builder (e.g., ent.<T>Query
) and accessing its methods requires type assertion. For example:
ent.InterceptFunc(func(next ent.Querier) ent.Querier {
return ent.QuerierFunc(func(ctx context.Context, query ent.Query) (ent.Value, error) {
if q, ok := query.(*ent.UserQuery); ok {
q.Where(user.Name("a8m"))
}
return next.Query(ctx, query)
})
})
However, the utilities generated by the intercept
feature flag enable the creation of generic interceptors that can be applied to any query type. The intercept
feature flag can be added to a project in one of two ways:
Configuration
- CLI
- Go
If you are using the default go generate config, add --feature intercept
option to the ent/generate.go
file as follows:
ent/generate.go
package ent
//go:generate go run -mod=mod entgo.io/ent/cmd/ent generate --feature intercept ./schema
It is recommended to add the schema/snapshot feature-flag along with the intercept
flag to enhance the development experience, for example:
//go:generate go run -mod=mod entgo.io/ent/cmd/ent generate --feature intercept,schema/snapshot ./schema
If you are using the configuration from the GraphQL documentation, add the feature flag as follows:
// +build ignore
package main
import (
"log"
"entgo.io/ent/entc"
"entgo.io/ent/entc/gen"
)
func main() {
opts := []entc.Option{
entc.FeatureNames("intercept"),
}
if err := entc.Generate("./schema", &gen.Config{}, opts...); err != nil {
log.Fatalf("running ent codegen: %v", err)
}
}
It is recommended to add the schema/snapshot feature-flag along with the intercept
flag to enhance the development experience, for example:
opts := []entc.Option{
- entc.FeatureNames("intercept"),
+ entc.FeatureNames("intercept", "schema/snapshot"),
}
Interceptors Registration
info
You should notice that similar to schema hooks, if you use the Interceptors
option in your schema, you MUST add the following import in the main package, because a circular import is possible between the schema package and the generated ent package:
import _ "<project>/ent/runtime"
Using the generated intercept
package
Once the feature flag was added to your project, the creation of interceptors is possible using the intercept
package:
- intercept.Func
- intercept.TraverseFunc
- intercept.NewQuery
client.Intercept(
intercept.Func(func(ctx context.Context, q intercept.Query) error {
// Limit all queries to 1000 records.
q.Limit(1000)
return nil
})
)
client.Intercept(
intercept.TraverseFunc(func(ctx context.Context, q intercept.Query) error {
// Apply a predicate/filter to all queries.
q.WhereP(predicate)
return nil
})
)
ent.InterceptFunc(func(next ent.Querier) ent.Querier {
return ent.QuerierFunc(func(ctx context.Context, query ent.Query) (ent.Value, error) {
// Get a generic query from a typed-query.
q, err := intercept.NewQuery(query)
if err != nil {
return nil, err
}
q.Limit(1000)
return next.Intercept(ctx, query)
})
})
Defining a Traverser
In some cases, there is a need to intercept graph traversals and modify their builders before continuing to the nodes returned by the query. For example, in the query below, we want to ensure that only active
users are traversed in any graph traversals in the system:
intercept.TraverseUser(func(ctx context.Context, q *ent.UserQuery) error {
q.Where(user.Active(true))
return nil
})
After defining and registering such Traverser, it will take effect on all graph traversals in the system. For example:
func TestTypedTraverser(t *testing.T) {
ctx := context.Background()
client := enttest.Open(t, dialect.SQLite, "file:ent?mode=memory&_fk=1")
defer client.Close()
a8m, nat := client.User.Create().SetName("a8m").SaveX(ctx), client.User.Create().SetName("nati").SetActive(false).SaveX(ctx)
client.Pet.CreateBulk(
client.Pet.Create().SetName("a").SetOwner(a8m),
client.Pet.Create().SetName("b").SetOwner(a8m),
client.Pet.Create().SetName("c").SetOwner(nat),
).ExecX(ctx)
// Get pets of all users.
if n := client.User.Query().QueryPets().CountX(ctx); n != 3 {
t.Errorf("got %d pets, want 3", n)
}
// Add an interceptor that filters out inactive users.
client.User.Intercept(
intercept.TraverseUser(func(ctx context.Context, q *ent.UserQuery) error {
q.Where(user.Active(true))
return nil
}),
)
// Only pets of active users are returned.
if n := client.User.Query().QueryPets().CountX(ctx); n != 2 {
t.Errorf("got %d pets, want 2", n)
}
}
Interceptors vs. Traversers
Both Interceptors
and Traversers
can be used to modify the behavior of queries, but they do so at different stages the execution. Interceptors function as middleware and allow modifying the query before it is executed and modifying the records after they are returned from the database. For this reason, they are applied only in the final stage of the query - during the actual execution of the statement on the database. On the other hand, Traversers are called one stage earlier, at each step of a graph traversal allowing them to modify both intermediate and final queries before they are joined together.
In summary, a Traverse function is a better fit for adding default filters to graph traversals while using an Intercept function is better for implementing logging or caching capabilities to the application.
client.User.Query().
QueryGroups(). // User traverse functions applied.
QueryPosts(). // Group traverse functions applied.
All(ctx) // Post traverse and intercept functions applied.
Examples
Soft Delete
The soft delete pattern is a common use-case for interceptors and hooks. The example below demonstrates how to add such functionality to all schemas in the project using ent.Mixin:
- Mixin
- Mixin usage
- Runtime usage
// SoftDeleteMixin implements the soft delete pattern for schemas.
type SoftDeleteMixin struct {
mixin.Schema
}
// Fields of the SoftDeleteMixin.
func (SoftDeleteMixin) Fields() []ent.Field {
return []ent.Field{
field.Time("delete_time").
Optional(),
}
}
type softDeleteKey struct{}
// SkipSoftDelete returns a new context that skips the soft-delete interceptor/mutators.
func SkipSoftDelete(parent context.Context) context.Context {
return context.WithValue(parent, softDeleteKey{}, true)
}
// Interceptors of the SoftDeleteMixin.
func (d SoftDeleteMixin) Interceptors() []ent.Interceptor {
return []ent.Interceptor{
intercept.TraverseFunc(func(ctx context.Context, q intercept.Query) error {
// Skip soft-delete, means include soft-deleted entities.
if skip, _ := ctx.Value(softDeleteKey{}).(bool); skip {
return nil
}
d.P(q)
return nil
}),
}
}
// Hooks of the SoftDeleteMixin.
func (d SoftDeleteMixin) Hooks() []ent.Hook {
return []ent.Hook{
hook.On(
func(next ent.Mutator) ent.Mutator {
return ent.MutateFunc(func(ctx context.Context, m ent.Mutation) (ent.Value, error) {
// Skip soft-delete, means delete the entity permanently.
if skip, _ := ctx.Value(softDeleteKey{}).(bool); skip {
return next.Mutate(ctx, m)
}
mx, ok := m.(interface {
SetOp(ent.Op)
Client() *gen.Client
SetDeleteTime(time.Time)
WhereP(...func(*sql.Selector))
})
if !ok {
return nil, fmt.Errorf("unexpected mutation type %T", m)
}
d.P(mx)
mx.SetOp(ent.OpUpdate)
mx.SetDeleteTime(time.Now())
return mx.Client().Mutate(ctx, m)
})
},
ent.OpDeleteOne|ent.OpDelete,
),
}
}
// P adds a storage-level predicate to the queries and mutations.
func (d SoftDeleteMixin) P(w interface{ WhereP(...func(*sql.Selector)) }) {
w.WhereP(
sql.FieldIsNull(d.Fields()[0].Descriptor().Name),
)
}
// Pet holds the schema definition for the Pet entity.
type Pet struct {
ent.Schema
}
// Mixin of the Pet.
func (Pet) Mixin() []ent.Mixin {
return []ent.Mixin{
SoftDeleteMixin{},
}
}
// Filter out soft-deleted entities.
pets, err := client.Pet.Query().All(ctx)
if err != nil {
return err
}
// Include soft-deleted entities.
pets, err := client.Pet.Query().All(schema.SkipSoftDelete(ctx))
if err != nil {
return err
}
Limit number of records
The following example demonstrates how to limit the number of records returned from the database using an interceptor function:
client.Intercept(
intercept.Func(func(ctx context.Context, q intercept.Query) error {
// LimitInterceptor limits the number of records returned from
// the database to 1000, in case Limit was not explicitly set.
if ent.QueryFromContext(ctx).Limit == nil {
q.Limit(1000)
}
return nil
}),
)
Multi-project support
The example below demonstrates how to write a generic interceptor that can be used in multiple projects:
- Definition
- Usage
// Project-level example. The usage of "entgo" package emphasizes that this interceptor does not rely on any generated code.
func SharedLimiter[Q interface{ Limit(int) }](f func(entgo.Query) (Q, error), limit int) entgo.Interceptor {
return entgo.InterceptFunc(func(next entgo.Querier) entgo.Querier {
return entgo.QuerierFunc(func(ctx context.Context, query entgo.Query) (entgo.Value, error) {
l, err := f(query)
if err != nil {
return nil, err
}
l.Limit(limit)
// LimitInterceptor limits the number of records returned from the
// database to the configured one, in case Limit was not explicitly set.
if entgo.QueryFromContext(ctx).Limit == nil {
l.Limit(limit)
}
return next.Query(ctx, query)
})
})
}
client1.Intercept(SharedLimiter(intercept1.NewQuery, limit))
client2.Intercept(SharedLimiter(intercept2.NewQuery, limit))