Quarry Docs
GitHub
Example

Feed/List Endpoint

A feed endpoint is just a list endpoint with stricter rules. Sort order must stay stable, cursor predicates must be explicit, and the handler should overfetch by one row so it can tell whether there is another page.

Quarry logo

The request and cursor

type FeedRequest struct {
    TenantID int
    Query    string
    Topics   []string
    Cursor   *FeedCursor
}

type FeedCursor struct {
    CreatedAt time.Time
    ID        int64
}

Cursor pagination is opinionated on purpose. Use the same columns in the same order every time or the feed will drift under load.

Build the feed query

func anySlice[T any](values []T) []any {
    out := make([]any, 0, len(values))
    for _, v := range values {
        out = append(out, v)
    }
    return out
}

func BuildFeedQuery(qq *quarry.Quarry, req FeedRequest) quarry.SQLer {
    q := qq.Select(
        "id",
        "title",
        "excerpt",
        "topic",
        "created_at",
    ).
        From("posts").
        Where(
            quarry.Eq("tenant_id", req.TenantID),
            quarry.OptionalILike("title", req.Query),
            quarry.OptionalIn("topic", anySlice(req.Topics)...),
        ).
        OrderBy("created_at DESC", "id DESC").
        Limit(21)

    if req.Cursor != nil {
        q = q.Where(quarry.Or(
            quarry.Lt("created_at", req.Cursor.CreatedAt),
            quarry.And(
                quarry.Eq("created_at", req.Cursor.CreatedAt),
                quarry.Lt("id", req.Cursor.ID),
            ),
        ))
    }

    return q
}
Why overfetch by one Fetch 21 rows when the page size is 20. The extra row tells you whether there is a next page without making a second query.

Hydrate the rows

type FeedItem struct {
    ID        int64      `db:"id"`
    Title     string     `db:"title"`
    Excerpt   string     `db:"excerpt"`
    Topic     string     `db:"topic"`
    CreatedAt time.Time  `db:"created_at"`
}
items, err := scan.All[FeedItem](ctx, db, BuildFeedQuery(qq, req))
if err != nil {
    return nil, nil, err
}

The scan layer does not care that this is a feed. It just turns the rows into the struct shape you asked for.

Trim the overfetch and build the next cursor

hasNext := len(items) == 21
if hasNext {
    items = items[:20]
}

var nextCursor *FeedCursor
if hasNext && len(items) > 0 {
    last := items[len(items)-1]
    nextCursor = &FeedCursor{
        CreatedAt: last.CreatedAt,
        ID:        last.ID,
    }
}

This is intentionally plain. The data flow is easier to understand when the feed logic is obvious rather than hidden behind a helper that tries to be clever.

What the SQL looks like

SELECT id, title, excerpt, topic, created_at
FROM posts
WHERE tenant_id = $1
  AND (LOWER(title) LIKE LOWER($2) OR topic IN ($3, $4))
  AND (
    created_at < $5
    OR (created_at = $5 AND id < $6)
  )
ORDER BY created_at DESC, id DESC
LIMIT 21
Keep the order stable Cursor pagination only works if the sort keys are the same keys used by the cursor filter. That is why the example keeps created_at and id paired.