PHI

HomeDocsRead Hooks

Read Hooks

Read hooks allow you to intercept FindUnique, FindFirst, FindMany, and Count operations before queries hit the database. You can inspect or append predicates, apply soft-delete/tenant filters, modify ordering or pagination, or short-circuit queries with cached data.


Schema Context

The examples in this guide reference the following Prisma schema:

model User {
  id         String   @id @default(cuid())
  email      String   @unique
  phoneNum   String   @unique
  password   String?
  role       UserRole @default(STUDENT)
  loginCount Int      @default(0)
  createdAt  DateTime @default(now())

  posts      Post[]
}

model Post {
  id        String   @id @default(cuid())
  title     String
  content   String
  published Boolean  @default(false)
  authorId  String?
}

Registration & Chaining

Register hooks on a model delegate using .Use(), passing a model extension (e.g. user.Extension):

db.User.Use(user.Extension{
    FindMany: func(ctx context.Context, args *phi.UserFindManyArgs, next phi.UserFindManyQuery) ([]*phi.User, error) {
        // Pre-hook: append a global tenant filter
        args.Where = append(args.Where, user.LoginCount.GTE(1))

        res, err := next(ctx, args)

        // Post-hook: process or cache results
        return res, err
    },
})

Hook Signatures

Hook Field Signature Return Type
FindUnique func(ctx context.Context, args *phi.UserFindUniqueArgs, next phi.UserFindUniqueQuery) (*phi.User, error)
FindFirst func(ctx context.Context, args *phi.UserFindFirstArgs, next phi.UserFindFirstQuery) (*phi.User, error)
FindMany func(ctx context.Context, args *phi.UserFindManyArgs, next phi.UserFindManyQuery) ([]*phi.User, error)
Count func(ctx context.Context, args *phi.UserCountArgs, next phi.UserCountQuery) (int64, error)

1. Inspecting & Appending Predicates (`Where`)

All read args contain Where []phi.PredicateOf[User]. Each predicate exposes .Column(), .Value(), and .Children() for type-safe inspection:

db.User.Use(user.Extension{
    FindMany: func(ctx context.Context, args *phi.UserFindManyArgs, next phi.UserFindManyQuery) ([]*phi.User, error) {
        // Inspect active filters
        for _, pred := range args.Where {
            if children := pred.Children(); len(children) > 0 {
                // Composite key predicate (e.g. @@unique([email, phoneNum]))
                for _, child := range children {
                    fmt.Printf("Composite constituent: %s = %v\n", child.Column, child.Value)
                }
            } else {
                // Standard scalar predicate
                fmt.Printf("Filter applied: %s = %v\n", pred.Column(), pred.Value())
            }
        }

        // Enforce role restriction
        args.Where = append(args.Where, user.Role.EQ(phi.UserRole_STUDENT))

        return next(ctx, args)
    },
})

Combining Predicates with Logical Operators (`Or` / `And`)

Group predicates using logical operators:

args.Where = append(args.Where,
    user.Or(
        user.Role.EQ(phi.UserRole_ADMIN),
        user.Role.EQ(phi.UserRole_TEACHER),
    ),
)

2. Setters vs. Direct Mutation

Every query argument struct provides chainable Set* helper methods for replacing values, as well as direct exported fields for appending:

Setter Method Target Arguments Replaces Field
SetWhere(...) FindUnique, FindFirst, FindMany, Count Where
SetOrderBy(...) FindFirst, FindMany OrderBy
SetCursor(...) FindFirst, FindMany Cursor
SetSkip(n int) FindFirst, FindMany, Count Skip (*int)
SetTake(n int) FindFirst, FindMany, Count Take (*int)

Example: Chainable Setters (`FindMany`)

db.User.Use(user.Extension{
    FindMany: func(ctx context.Context, args *phi.UserFindManyArgs, next phi.UserFindManyQuery) ([]*phi.User, error) {
        args.SetWhere(user.LoginCount.GTE(10)).
            SetOrderBy(user.LoginCount.Desc(), user.Email.Asc()).
            SetSkip(0).
            SetTake(50)

        return next(ctx, args)
    },
})

3. Operations Overview

`FindUnique`

Receives *phi.UserFindUniqueArgs with Where []phi.PredicateOf[User] and Select *phi.UserSelect.

db.User.Use(user.Extension{
    FindUnique: func(ctx context.Context, args *phi.UserFindUniqueArgs, next phi.UserFindUniqueQuery) (*phi.User, error) {
        // Force select user posts
        args.Select.Posts = post.Query().Where(post.Published.EQ(true))

        return next(ctx, args)
    },
})

`FindFirst` & `FindMany`

Support full pagination, sorting, and relation preloading:

db.User.Use(user.Extension{
    FindFirst: func(ctx context.Context, args *phi.UserFindFirstArgs, next phi.UserFindFirstQuery) (*phi.User, error) {
        // Enforce order by highest login count
        args.SetOrderBy(user.LoginCount.Desc())
        return next(ctx, args)
    },
})

`Count`

Receives *phi.UserCountArgs with Where, Skip, and Take:

db.User.Use(user.Extension{
    Count: func(ctx context.Context, args *phi.UserCountArgs, next phi.UserCountQuery) (int64, error) {
        args.Where = append(args.Where, user.LoginCount.GT(0))
        return next(ctx, args)
    },
})

4. Query Short-Circuiting & Caching

Skip database calls by returning cached data directly:

db.User.Use(user.Extension{
    FindUnique: func(ctx context.Context, args *phi.UserFindUniqueArgs, next phi.UserFindUniqueQuery) (*phi.User, error) {
        cacheKey := fmt.Sprintf("user:%v", args.Where[0].Value())

        if cached, found := cache.Get(cacheKey); found {
            return cached.(*phi.User), nil
        }

        res, err := next(ctx, args)
        if err == nil && res != nil {
            cache.Set(cacheKey, res)
        }
        return res, err
    },
})