Skip to content

Issue: Relations Not Preserved in Queries for Pagination #1247

@mdsohelmia

Description

@mdsohelmia

When using Bun ORM with SQLite (sqlitedialect) in a pagination implementation, relations specified in the parent query (e.g., .Relation("Products")) are not preserved in cloned queries, resulting in related data not being loaded. This issue occurs when cloning a SelectQuery and binding it to a generic slice (Model(&items)), which is common in pagination scenarios.
Expected Behavior
Cloning a SelectQuery with .Relation("Products") should preserve the relation in the cloned query, and the related data (e.g., Products for a User) should be populated when scanning the results.
Actual Behavior
The cloned query does not include the LEFT JOIN for the specified relation in the generated SQL, resulting in empty relation fields (e.g., User.Products is nil or empty).
Steps to Reproduce

Define User and Product models with a has-many relationship.
Create a pagination function that clones the parent query for counting and selecting paginated data.
Execute the paginated query with .Relation("Products") on the parent query.
Observe that the Products relation is not loaded in the results.

Minimal Reproducible Example

package main

import (
	"context"
	"database/sql"
	"fmt"
	"log"

	"github.com/gin-gonic/gin"
	"github.com/uptrace/bun"
	"github.com/uptrace/bun/dialect/sqlitedialect"
	"github.com/uptrace/bun/driver/sqliteshim"
	"github.com/uptrace/bun/extra/bundebug"
)

type User struct {
	bun.BaseModel `bun:"table:users"`
	ID            int64     `bun:"id,pk,autoincrement"`
	Name          string    `bun:"name"`
	Products      []Product `bun:"rel:has-many,join:id=user_id"`
}

type Product struct {
	bun.BaseModel `bun:"table:products"`
	ID            int64  `bun:"id,pk,autoincrement"`
	UserID        int64  `bun:"user_id"`
	Name          string `bun:"name"`
}

type Params struct {
	Page     int
	PageSize int
}

type Meta struct {
	Total      int `json:"total"`
	Page       int `json:"page"`
	PageSize   int `json:"page_size"`
	TotalPages int `json:"total_pages"`
}

type Links struct {
	First string `json:"first"`
	Prev  string `json:"prev,omitempty"`
	Next  string `json:"next,omitempty"`
	Last  string `json:"last"`
}

type Result[T any] struct {
	Data  []T
	Meta  Meta
	Links Links
}

func main() {
	ctx := context.Background()

	sqldb, err := sql.Open(sqliteshim.ShimName, "file::memory:?cache=shared")
	if err != nil {
		panic(err)
	}
	db := bun.NewDB(sqldb, sqlitedialect.New())
	db.AddQueryHook(bundebug.NewQueryHook(bundebug.WithVerbose(true)))

	// Create tables
	err = db.ResetModel(ctx, (*User)(nil), (*Product)(nil))
	if err != nil {
		log.Fatalf("Failed to create tables: %v", err)
	}

	// Insert sample data
	users := []User{
		{Name: "Alice", Products: []Product{{Name: "Product A"}, {Name: "Product B"}}},
		{Name: "Bob", Products: []Product{{Name: "Product C"}}},
	}
	_, err = db.NewInsert().Model(&users).Exec(ctx)
	if err != nil {
		log.Fatalf("Failed to insert sample data: %v", err)
	}

	// Initialize Gin router
	r := gin.Default()
	r.GET("/users", func(c *gin.Context) {
		query := db.NewSelect().Model((*User)(nil)).Relation("Products")
		result, err := paginateQuery[User](c, query)
		if err != nil {
			c.JSON(500, gin.H{"error": err.Error()})
			return
		}
		c.JSON(200, result)
	})

	// Start server
	if err := r.Run(":8080"); err != nil {
		log.Fatalf("Failed to start server: %v", err)
	}
}

func paginateQuery[T any](c *gin.Context, q *bun.SelectQuery) (Result[T], error) {
	var result Result[T]
	var items []T
	ctx := c.Request.Context()

	params := Params{Page: 1, PageSize: 10}

	// Count total
	countQuery := q.Clone().Limit(0).Offset(0)
	total, err := countQuery.Count(ctx)
	if err != nil {
		return result, fmt.Errorf("count query failed: %w", err)
	}

	// Build select query
	selectQuery := q.Model(&items).Limit(params.PageSize).Offset((params.Page - 1) * params.PageSize)

	// Log SQL
	sql := selectQuery.String()

	fmt.Println("Generated SQL for selectQuery:", sql)

	if err := selectQuery.Scan(ctx); err != nil {
		return result, fmt.Errorf("select query failed: %w", err)
	}

	result = Result[T]{
		Data: items,
		Meta: Meta{
			Total:      total,
			Page:       params.Page,
			PageSize:   params.PageSize,
			TotalPages: (total + params.PageSize - 1) / params.PageSize,
		},
		Links: Links{
			First: "/users?page=1&page_size=10",
			Last:  fmt.Sprintf("/users?page=%d&page_size=10", (total+params.PageSize-1)/params.PageSize),
		},
	}

	return result, nil
}

Environment

Bun Version: Run go list -m github.com/uptrace/bun to confirm (e.g., v1.1.x).
Database: SQLite (sqlitedialect).
Go Version: Run go version to confirm (e.g., go1.20).

Observed Output
When accessing http://localhost:8080/users, the Products field in each User is nil or empty, despite .Relation("Products") being specified in the parent query.
Generated SQL (example):
Generated SQL for selectQuery: SELECT "user"."id", "user"."name" FROM "users" LIMIT 10 OFFSET 0

Note: The LEFT JOIN products ON products.user_id = users.id is missing, indicating the relation is not preserved.
JSON Response (example):

{
  "data": [
    {"id": 1, "name": "Alice", "products": null},
    {"id": 2, "name": "Bob", "products": null}
  ],
  "meta": {
    "total": 2,
    "page": 1,
    "page_size": 10,
    "total_pages": 1
  },
  "links": {
    "first": "/users?page=1&page_size=10",
    "last": "/users?page=1&page_size=10"
  }
}

Expected Output
The Products field should be populated:

{
  "data": [
    {
      "id": 1,
      "name": "Alice",
      "products": [
        {"id": 1, "user_id": 1, "name": "Product A"},
        {"id": 2, "user_id": 1, "name": "Product B"}
      ]
    },
    {
      "id": 2,
      "name": "Bob",
      "products": [
        {"id": 3, "user_id": 2, "name": "Product C"}
      ]
    }
  ],
  "meta": {
    "total": 2,
    "page": 1,
    "page_size": 10,
    "total_pages": 1
  },
  "links": {
    "first": "/users?page=1&page_size=10",
    "last": "/users?page=1&page_size=10"
  }
}

Expected SQL:
SELECT "user"."id", "user"."name", "products"."id" AS "products__id", "products"."user_id" AS "products__user_id", "products"."name" AS "products__name"
FROM "users"
LEFT JOIN "products" ON "products"."user_id" = "user"."id"
LIMIT 10 OFFSET 0

Additional Notes

The issue appears when cloning the query (q.Clone()) and binding it to a generic slice (Model(&items)).
A standalone query (db.NewSelect().Model(&users).Relation("Products").Scan(ctx)) works correctly, loading the Products relation.
This suggests a bug in Bun’s query cloning or model binding with generics, possibly related to how relation metadata is handled in SQLite.

Workaround
Explicitly re-applying the relation in the pagination function works:
selectQuery := q.Clone().Model(&items).Relation("Products").Limit(params.PageSize).Offset((params.Page - 1) * params.PageSize)

However, this requires duplicating the relation specification, which is redundant and error-prone.
References

Bun Documentation: Table Relationships
Bun GitHub: uptrace/bun

Request
Please investigate why .Relation("Products") is not preserved in cloned queries with SQLite and provide a fix or guidance on avoiding this issue without duplicating relation specifications.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions