Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

m2m relation detection incorrect (v1.2.7) #1100

Closed
emoss08 opened this issue Jan 3, 2025 · 6 comments · Fixed by #1101
Closed

m2m relation detection incorrect (v1.2.7) #1100

emoss08 opened this issue Jan 3, 2025 · 6 comments · Fixed by #1101

Comments

@emoss08
Copy link
Contributor

emoss08 commented Jan 3, 2025

Description

After upgrading from bun v1.2.6 to bun v1.2.7 there seems to be a regression when it comes to m2m relation detection

Reproduction

// models.go
type User struct {
	bun.BaseModel `bun:"table:users,alias:usr" json:"-"`
	ID            pulid.ID           `json:"id" bun:"id,pk,type:VARCHAR(100)"`
	Status        domain.Status      `json:"status" bun:"status,type:status_enum,notnull,default:'Active'"`
	Name          string             `json:"name" bun:"name,type:VARCHAR(255),notnull"`
	Username      string             `json:"username" bun:"username,type:VARCHAR(20),notnull"`
	Roles         []*permission.Role `json:"roles,omitempty" bun:"m2m:user_roles,join:User=Role"`
}

type Role struct {
	bun.BaseModel `bun:"table:roles,alias:r" json:"-"`
	ID            pulid.ID      `json:"id" bun:",pk,type:VARCHAR(100)"`
	Name          string        `json:"name" bun:"name,type:VARCHAR(100),notnull"`
	Description   string        `json:"description" bun:"description,type:TEXT"`
	ParentRoleID  *pulid.ID     `json:"parentRoleId,omitempty" bun:"parent_role_id,type:VARCHAR(100),nullzero"`
	Permissions   []*Permission `json:"permissions,omitempty" bun:"m2m:role_permissions,join:Role=Permission"`
	ParentRole    *Role         `json:"parentRole,omitempty" bun:"rel:belongs-to,join:parent_role_id=id"`
	ChildRoles    []*Role       `json:"childRoles,omitempty" bun:"rel:has-many,join:id=parent_role_id"`
}

type RolePermission struct {
	bun.BaseModel `bun:"table:role_permissions,alias:rp" json:"-"`
	RoleID        pulid.ID    `json:"roleId" bun:"role_id,pk,type:VARCHAR(100),notnull"`
	PermissionID  pulid.ID    `json:"permissionId" bun:"permission_id,pk,type:VARCHAR(100),notnull"`
	Role          *Role       `json:"-" bun:"rel:belongs-to,join:role_id=id"`
	Permission    *Permission `json:"-" bun:"rel:belongs-to,join:permission_id=id"`
}
// repository.go
type permissionRepository struct {
	db    db.Connection
	l     *zerolog.Logger
	cache repositories.PermissionCacheRepository
}

func (pr *permissionRepository) GetUserRoles(ctx context.Context, userID pulid.ID) ([]*string, error) {
	dba, err := pr.db.DB(ctx)
	if err != nil {
		return nil, eris.Wrap(err, "get database connection")
	}

	log := pr.l.With().
		Str("operation", "GetUserRoles").
		Str("userId", userID.String()).
		Logger()

	// Try to get from cache first
	roles, err := pr.cache.GetUserRoles(ctx, userID)
	if err == nil && len(roles) > 0 {
		log.Trace().Int("count", len(roles)).Msg("got roles from cache")
		return roles, nil
	}

	// Get roles from database
	dbRoles := make([]*permission.Role, 0)
	err = dba.NewSelect().
		Model(&dbRoles).
		Join("JOIN user_roles ur ON ur.role_id = r.id").
		Where("ur.user_id = ?", userID).
		Where("r.status = ?", permission.StatusActive).
		Where("r.expires_at IS NULL OR r.expires_at > ?", time.Now().Unix()).
		Scan(ctx)
	if err != nil {
		log.Error().Err(err).Msg("failed to get user roles")
		return nil, eris.Wrap(err, "failed to get user roles")
	}

	roleNames := make([]*string, len(dbRoles))
	for i, role := range dbRoles {
		roleNames[i] = &role.Name
	}

	// Cache the roles
	if err = pr.cache.SetUserRoles(ctx, userID, roleNames); err != nil {
		log.Warn().Err(err).Msg("failed to cache user roles")
	}

	log.Trace().Int("count", len(roleNames)).Msg("got roles from database")
	return roleNames, nil
}

Expected Behavior

The User base model should not be detected as a m2m relation to Roles.

Actual Behavior

The user base model is being detected as m2m relation directly to Roles and causing the following error.

[bun]  18:42:14.981   SELECT 576µs  
SELECT "ur"."user_id",
       "r"."id",
       "r"."name",
       "r"."description",
       "r"."role_type",
       "r"."is_system",
       "r"."priority",
       "r"."status",
       "r"."expires_at",
       "r"."created_at",
       "r"."updated_at",
       "r"."business_unit_id",
       "r"."organization_id",
       "r"."parent_role_id",
       "r"."metadata"
FROM   "roles" AS "r"
       join "user_roles" AS "ur"
         ON ( "ur"."user_id" ) IN ( 'usr_01JGJ9J7RVQBRJ176X5CHEDFM8' )
WHERE  ( "r"."id" = "ur"."role_id" )

*errors.errorString: bun: m2m relation=Roles does not have base model=User with key=["usr_01JGJ9J7RVQBRJ176X5CHEDFM8"] (check join conditions) 
@j2gg0s
Copy link
Collaborator

j2gg0s commented Jan 4, 2025

Which dialect are you using?
Postgresql?

Is the usage like this? It works fine locally for me.

package main

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

	"github.com/uptrace/bun"
	"github.com/uptrace/bun/dialect/pgdialect"
	"github.com/uptrace/bun/driver/pgdriver"
	"github.com/uptrace/bun/extra/bundebug"
)

// models.go
type User struct {
	bun.BaseModel `bun:"table:users,alias:usr" json:"-"`
	ID            ID      `json:"id" bun:"id,pk,type:VARCHAR(100)"`
	Status        string  `json:"status" bun:"status,type:VARCHAR(100),notnull,default:'Active'"`
	Name          string  `json:"name" bun:"name,type:VARCHAR(255),notnull"`
	Username      string  `json:"username" bun:"username,type:VARCHAR(20),notnull"`
	Roles         []*Role `json:"roles,omitempty" bun:"m2m:user_roles,join:User=Role"`
}

type UserRole struct {
	bun.BaseModel `bun:"table:user_roles"`
	UserID        ID    `bun:",pk"`
	User          *User `bun:"rel:belongs-to,join:user_id=id"`
	RoleID        ID    `bun:",pk"`
	Role          *Role `bun:"rel:belongs-to,join:role_id=id"`
}

type Role struct {
	bun.BaseModel `bun:"table:roles,alias:r" json:"-"`
	ID            ID            `json:"id" bun:",pk,type:VARCHAR(100)"`
	Name          string        `json:"name" bun:"name,type:VARCHAR(100),notnull"`
	Description   string        `json:"description" bun:"description,type:TEXT"`
	ParentRoleID  *ID           `json:"parentRoleId,omitempty" bun:"parent_role_id,type:VARCHAR(100),nullzero"`
	Permissions   []*Permission `json:"permissions,omitempty" bun:"m2m:role_permissions,join:Role=Permission"`
	ParentRole    *Role         `json:"parentRole,omitempty" bun:"rel:belongs-to,join:parent_role_id=id"`
	ChildRoles    []*Role       `json:"childRoles,omitempty" bun:"rel:has-many,join:id=parent_role_id"`
}

type RolePermission struct {
	bun.BaseModel `bun:"table:role_permissions,alias:rp" json:"-"`
	RoleID        ID          `json:"roleId" bun:"role_id,pk,type:VARCHAR(100),notnull"`
	PermissionID  ID          `json:"permissionId" bun:"permission_id,pk,type:VARCHAR(100),notnull"`
	Role          *Role       `json:"-" bun:"rel:belongs-to,join:role_id=id"`
	Permission    *Permission `json:"-" bun:"rel:belongs-to,join:permission_id=id"`
}

type ID string

type Permission struct {
	bun.BaseModel `bun:"table:permissions,alias:p" json:"-"`
	ID            ID `bun:",pk,type:VARCHAR(100)"`
}

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

	sqldb := sql.OpenDB(pgdriver.NewConnector(pgdriver.WithDSN("postgres://bun:bun@localhost:5433/bun?sslmode=disable")))

	db := bun.NewDB(sqldb, pgdialect.New())
	db.AddQueryHook(bundebug.NewQueryHook(
		bundebug.WithVerbose(true),
		bundebug.FromEnv("BUNDEBUG"),
	))

	db.RegisterModel((*UserRole)(nil), (*RolePermission)(nil))
	if err := db.ResetModel(ctx, (*UserRole)(nil), (*User)(nil), (*Role)(nil), (*RolePermission)(nil), (*Permission)(nil)); err != nil {
		panic(err)
	}

	user := User{
		ID: "ua",
	}
	if _, err := db.NewInsert().Model(&user).Exec(ctx); err != nil {
		panic(err)
	}
	role := Role{
		ID: "ra",
	}
	if _, err := db.NewInsert().Model(&role).Exec(ctx); err != nil {
		panic(err)
	}
	ur := UserRole{
		UserID: "ua",
		RoleID: "ra",
	}
	if _, err := db.NewInsert().Model(&ur).Exec(ctx); err != nil {
		panic(err)
	}

	// Get roles from database
	dbRoles := make([]*Role, 0)
	if err := db.NewSelect().
		Model(&dbRoles).
		Join("JOIN user_roles ur ON ur.role_id = r.id").
		Where("ur.user_id = ?", "ua").
		Scan(ctx); err != nil {
		panic(err)
	}
	fmt.Println(len(dbRoles))
}
[bun]  09:22:57.921   DROP TABLE           15.314ms  DROP TABLE IF EXISTS "user_roles" CASCADE
[bun]  09:22:57.930   CREATE TABLE          8.358ms  CREATE TABLE "user_roles" ("user_id" VARCHAR NOT NULL, "role_id" VARCHAR NOT NULL, PRIMARY KEY ("user_id", "role_id"))
[bun]  09:22:57.930   DROP TABLE              240µs  DROP TABLE IF EXISTS "users" CASCADE
[bun]  09:22:57.934   CREATE TABLE          3.816ms  CREATE TABLE "users" ("id" VARCHAR(100) NOT NULL, "status" VARCHAR(100) NOT NULL DEFAULT 'Active', "name" VARCHAR(255) NOT NULL, "username" VARCHAR(20) NOT NULL, PRIMARY KEY ("id"))
[bun]  09:22:57.934   DROP TABLE              251µs  DROP TABLE IF EXISTS "roles" CASCADE
[bun]  09:22:57.937   CREATE TABLE          2.509ms  CREATE TABLE "roles" ("id" VARCHAR(100) NOT NULL, "name" VARCHAR(100) NOT NULL, "description" TEXT, "parent_role_id" VARCHAR(100), PRIMARY KEY ("id"))
[bun]  09:22:57.937   DROP TABLE              109µs  DROP TABLE IF EXISTS "role_permissions" CASCADE
[bun]  09:22:57.939   CREATE TABLE          1.656ms  CREATE TABLE "role_permissions" ("role_id" VARCHAR(100) NOT NULL, "permission_id" VARCHAR(100) NOT NULL, PRIMARY KEY ("role_id", "permission_id"))
[bun]  09:22:57.939   DROP TABLE              103µs  DROP TABLE IF EXISTS "permissions" CASCADE
[bun]  09:22:57.940   CREATE TABLE          1.356ms  CREATE TABLE "permissions" ("id" VARCHAR(100) NOT NULL, PRIMARY KEY ("id"))
[bun]  09:22:57.941   INSERT                  936µs  INSERT INTO "users" ("id", "status", "name", "username") VALUES ('ua', DEFAULT, '', '') RETURNING "status"
[bun]  09:22:57.942   INSERT                  631µs  INSERT INTO "roles" ("id", "name", "description", "parent_role_id") VALUES ('ra', '', '', DEFAULT) RETURNING "parent_role_id"
[bun]  09:22:57.943   INSERT                  772µs  INSERT INTO "user_roles" ("user_id", "role_id") VALUES ('ua', 'ra')
[bun]  09:22:57.944   SELECT                1.523ms  SELECT "r"."id", "r"."name", "r"."description", "r"."parent_role_id" FROM "roles" AS "r" JOIN user_roles ur ON ur.role_id = r.id WHERE (ur.user_id = 'ua')
1

@emoss08
Copy link
Contributor Author

emoss08 commented Jan 4, 2025

Apologies for not including that I use postgresql in the original response.

Apologies again as I was mistaken before on what was actually causing the error. It appears this is the culprit, not what I reported originally.

func (ur *userRepository) GetByID(ctx context.Context, userID pulid.ID, opts repositories.GetByIDOptions) (*user.User, error) {
	dba, err := ur.db.DB(ctx)
	if err != nil {
		return nil, eris.Wrap(err, "get database connection")
	}

	u := new(user.User)

	q := dba.NewSelect().Model(u).Where("usr.id = ?", userID)

	// Include roles and permissions if needed
	if opts.IncludeRoles {
		q.Relation("Roles").Relation("Roles.Permissions")
	}

	// Include organizations if needed
	if opts.IncludeOrgs {
		q.Relation("Organizations")
	}

	if err = q.Scan(ctx); err != nil {
		return nil, eris.Wrapf(err, "failed to get user by id %s", userID)
	}

	return u, nil
}

Try adding just q.Relation("Roles").Relation("Roles.Permissions") and let me know if you experience the error.

Additionally, if I take that out and just leave q.Relation("Organizations") I get the same error that I experienced before.

*errors.errorString: bun: m2m relation=Organizations does not have base model=User with key=["usr_01JGJ9J7RVQBRJ176X5CHEDFM8"] (check join conditions) 

edit:

Here is how I use the database connection.

// Connection is a wrapper around the bun.DB type that provides a way to get a database connection
// and close the connection.
type Connection interface {
	// DB returns a database connection.
	DB(ctx context.Context) (*bun.DB, error)

	// Close closes the database connection.
	Close() error
}

type ConnectionParams struct {
	fx.In

	Config *config.Manager
	Logger *logger.Logger
	LC     fx.Lifecycle
}

var (
	DBConnStringEmpty = eris.New("database connection string is empty")
	DBConfigNil       = eris.New("database config is nil")
	AppConfigNil      = eris.New("application config is nil")
)

type connection struct {
	cfg  *config.Manager
	log  *zerolog.Logger
	db   *bun.DB
	pool *pgxpool.Pool
	mu   sync.RWMutex
}

func NewConnection(p ConnectionParams) db.Connection {
	log := p.Logger.With().
		Str("component", "postgres").
		Str("service", "connection").
		Logger()

	conn := &connection{
		cfg: p.Config,
		log: &log,
	}

	p.LC.Append(fx.Hook{
		OnStart: func(ctx context.Context) error {
			_, err := conn.DB(ctx)
			if err != nil {
				return err
			}

			return nil
		},
		OnStop: func(context.Context) error {
			return conn.Close()
		},
	})

	return conn
}

func (c *connection) DB(ctx context.Context) (*bun.DB, error) {
	c.mu.RLock()
	if c.db != nil {
		defer c.mu.RUnlock()
		return c.db, nil
	}
	c.mu.RUnlock()

	c.mu.Lock()
	defer c.mu.Unlock()

	// Double-check after acquiring write lock
	if c.db != nil {
		return c.db, nil
	}

	dsn := c.cfg.GetDSN()
	if dsn == "" {
		return nil, DBConnStringEmpty
	}

	appCfg := c.cfg.App()
	if appCfg == nil {
		return nil, AppConfigNil
	}

	dbCfg := c.cfg.Database()
	if dbCfg == nil {
		return nil, DBConfigNil
	}

	// Parse the database connection string
	cfg, err := pgxpool.ParseConfig(dsn)
	if err != nil {
		return nil, eris.Wrap(err, "failed to parse database config")
	}

	cfg.ConnConfig.DefaultQueryExecMode = pgx.QueryExecModeSimpleProtocol

	// Setup connection pool
	pool, err := pgxpool.NewWithConfig(ctx, cfg)
	if err != nil {
		return nil, eris.Wrap(err, "failed to create database pool")
	}
	c.pool = pool

	sqldb := stdlib.OpenDBFromPool(pool)
	bunDB := bun.NewDB(sqldb, pgdialect.New(), bun.WithDiscardUnknownColumns())

	// If the environment is development and debug is enabled, add a query hook to the database
	if appCfg.Environment == "development" && dbCfg.Debug {
		bunDB.AddQueryHook(bundebug.NewQueryHook(
			bundebug.WithVerbose(true),
			bundebug.FromEnv("BUNDEBUG"),
		))
	}

	bunDB.RegisterModel(registry.RegisterEntities()...)

	// Configure connection pool settings
	sqldb.SetConnMaxIdleTime(time.Duration(dbCfg.ConnMaxIdleTime) * time.Second)
	sqldb.SetMaxOpenConns(dbCfg.MaxConnections)
	sqldb.SetMaxIdleConns(dbCfg.MaxIdleConns)
	sqldb.SetConnMaxLifetime(time.Duration(dbCfg.ConnMaxLifetime) * time.Second)

	// Verify connection
	if err = bunDB.PingContext(ctx); err != nil {
		return nil, eris.Wrap(err, "failed to ping database")
	}

	c.db = bunDB
	c.log.Info().Msg("🚀 Established connection to Postgres database!")

	return c.db, nil
}

func (c *connection) Close() error {
	c.mu.Lock()
	defer c.mu.Unlock()

	// Close the connection pool
	if c.pool != nil {
		c.pool.Close()
	}

	// Close the database connection
	if c.db != nil {
		if err := c.db.Close(); err != nil {
			return eris.Wrap(err, "failed to close database connection")
		}
	}

	return nil
}

@j2gg0s
Copy link
Collaborator

j2gg0s commented Jan 4, 2025

Don’t mind these.

I replaced the driver with pgx, but the following code is still correct.
Do you have a way to reproduce this error?
Or can you tell me the approximate structure of the Organization?

package main

import (
	"context"
	"fmt"

	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx/v5/pgxpool"
	"github.com/jackc/pgx/v5/stdlib"
	"github.com/uptrace/bun"
	"github.com/uptrace/bun/dialect/pgdialect"
	"github.com/uptrace/bun/extra/bundebug"
)

// models.go
type User struct {
	bun.BaseModel `bun:"table:users,alias:usr" json:"-"`
	ID            ID      `json:"id" bun:"id,pk,type:VARCHAR(100)"`
	Status        string  `json:"status" bun:"status,type:VARCHAR(100),notnull,default:'Active'"`
	Name          string  `json:"name" bun:"name,type:VARCHAR(255),notnull"`
	Username      string  `json:"username" bun:"username,type:VARCHAR(20),notnull"`
	Roles         []*Role `json:"roles,omitempty" bun:"m2m:user_roles,join:User=Role"`
}

type UserRole struct {
	bun.BaseModel `bun:"table:user_roles"`
	UserID        ID    `bun:",pk"`
	User          *User `bun:"rel:belongs-to,join:user_id=id"`
	RoleID        ID    `bun:",pk"`
	Role          *Role `bun:"rel:belongs-to,join:role_id=id"`
}

type Role struct {
	bun.BaseModel `bun:"table:roles,alias:r" json:"-"`
	ID            ID            `json:"id" bun:",pk,type:VARCHAR(100)"`
	Name          string        `json:"name" bun:"name,type:VARCHAR(100),notnull"`
	Description   string        `json:"description" bun:"description,type:TEXT"`
	ParentRoleID  *ID           `json:"parentRoleId,omitempty" bun:"parent_role_id,type:VARCHAR(100),nullzero"`
	Permissions   []*Permission `json:"permissions,omitempty" bun:"m2m:role_permissions,join:Role=Permission"`
	ParentRole    *Role         `json:"parentRole,omitempty" bun:"rel:belongs-to,join:parent_role_id=id"`
	ChildRoles    []*Role       `json:"childRoles,omitempty" bun:"rel:has-many,join:id=parent_role_id"`
}

type RolePermission struct {
	bun.BaseModel `bun:"table:role_permissions,alias:rp" json:"-"`
	RoleID        ID          `json:"roleId" bun:"role_id,pk,type:VARCHAR(100),notnull"`
	PermissionID  ID          `json:"permissionId" bun:"permission_id,pk,type:VARCHAR(100),notnull"`
	Role          *Role       `json:"-" bun:"rel:belongs-to,join:role_id=id"`
	Permission    *Permission `json:"-" bun:"rel:belongs-to,join:permission_id=id"`
}

type ID string

type Permission struct {
	bun.BaseModel `bun:"table:permissions,alias:p" json:"-"`
	ID            ID `bun:",pk,type:VARCHAR(100)"`
}

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

	// sqldb := sql.OpenDB(pgdriver.NewConnector(pgdriver.WithDSN("postgres://bun:bun@localhost:5433/bun?sslmode=disable")))
	cfg, err := pgxpool.ParseConfig("postgres://bun:bun@localhost:5433/bun?sslmode=disable")
	if err != nil {
		panic(err)
	}
	cfg.ConnConfig.DefaultQueryExecMode = pgx.QueryExecModeSimpleProtocol

	pool, err := pgxpool.NewWithConfig(ctx, cfg)
	if err != nil {
		panic(err)
	}

	sqldb := stdlib.OpenDBFromPool(pool)

	db := bun.NewDB(sqldb, pgdialect.New())
	db.AddQueryHook(bundebug.NewQueryHook(
		bundebug.WithVerbose(true),
		bundebug.FromEnv("BUNDEBUG"),
	))

	db.RegisterModel((*UserRole)(nil), (*RolePermission)(nil))
	if err := db.ResetModel(ctx, (*UserRole)(nil), (*User)(nil), (*Role)(nil), (*RolePermission)(nil), (*Permission)(nil)); err != nil {
		panic(err)
	}

	{
		user := User{
			ID: "ua",
		}
		if _, err := db.NewInsert().Model(&user).Exec(ctx); err != nil {
			panic(err)
		}
		role := Role{
			ID: "ra",
		}
		if _, err := db.NewInsert().Model(&role).Exec(ctx); err != nil {
			panic(err)
		}
		ur := UserRole{
			UserID: "ua",
			RoleID: "ra",
		}
		if _, err := db.NewInsert().Model(&ur).Exec(ctx); err != nil {
			panic(err)
		}
	}

	{
		user := User{}
		if err := db.NewSelect().
			Model(&user).
			Where("usr.id = ?", "ua").
			Relation("Roles").
			Relation("Roles.Permissions").
			Scan(ctx); err != nil {
			panic(err)
		}
		fmt.Println(user)
	}
	{
		user := User{}
		if err := db.NewSelect().
			Model(&user).
			Where("usr.id = ?", "ua").
			Relation("Roles").
			Scan(ctx); err != nil {
			panic(err)
		}
		fmt.Println(user)
	}
}

@emoss08
Copy link
Contributor Author

emoss08 commented Jan 4, 2025

Found the issue, it's caused by how I generate ID's.

I assume this change causes this to no longer work in v1.2.7? cb8c42c

// pulid/pulid.go
package pulid

import (
	"crypto/rand"
	"database/sql/driver"
	"fmt"
	"time"

	"github.com/oklog/ulid/v2"
	"github.com/rotisserie/eris"
)

var (
	ErrScanningNil   = eris.New("pulid: scanning nil into PULID")
	ErrInvalidLength = eris.New("pulid: invalid length")
)

// ID implements a PULID - a prefixed ULID.
type ID string

func (u ID) IsNil() bool { return u == "" }

func (u ID) IsNotNil() bool { return !u.IsNil() }

var Nil = ID("")

// The default entropy source.
var defaultEntropySource *ulid.MonotonicEntropy

func init() {
	// Seed the default entropy source.
	defaultEntropySource = ulid.Monotonic(rand.Reader, 0)
}

// newULID returns a new ULID for time.Now() using the default entropy source.
func newULID() ulid.ULID {
	return ulid.MustNew(ulid.Timestamp(time.Now()), defaultEntropySource)
}

// MustNew returns a new PULID for time.Now() given a prefix. This uses the default entropy source.
func MustNew(prefix string) ID { return ID(prefix + newULID().String()) }

// Must return a
func Must(prefix string) *ID {
	id := MustNew(prefix)
	return &id
}

// Scan implements the Scanner interface.
func (u *ID) Scan(src any) error {
	if src == nil {
		return ErrScanningNil
	}
	switch v := src.(type) {
	case string:
		*u = ID(v)
	case ID:
		*u = v
	default:
		return fmt.Errorf("pulid: unexpected type, %T", v)
	}
	return nil
}

// Value implements the driver Valuer interface.
func (u ID) Value() (driver.Value, error) {
	return string(u), nil
}

// String returns the string representation of the PULID.
func (u ID) String() string { return string(u) }

// Parse parses a PULID from a string.
func Parse(s string) (ID, error) {
	if len(s) < 27 {
		return Nil, ErrInvalidLength
	}
	return ID(s), nil
}

// MustParse parses a PULID from a string. If the string is not a valid PULID, it panics.
func MustParse(s string) (ID, error) {
	id, err := Parse(s)
	if err != nil {
		return Nil, eris.Wrap(err, "pulid: failed to parse PULID")
	}
	return id, nil
}

//main.go
package main

import (
	"context"
	"fmt"

	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx/v5/pgxpool"
	"github.com/jackc/pgx/v5/stdlib"
	"github.com/trenova-test/pulid"
	"github.com/uptrace/bun"
	"github.com/uptrace/bun/dialect/pgdialect"
	"github.com/uptrace/bun/extra/bundebug"
)

type BusinessUnit struct {
	bun.BaseModel `bun:"table:business_units,alias:bu" json:"-"`

	ID                   pulid.ID      `json:"id" bun:",pk,type:VARCHAR(100)"`
	ParentBusinessUnitID *pulid.ID     `json:"parentBusinessUnitId" bun:"parent_business_unit_id,type:VARCHAR(100),nullzero"`
	Name                 string        `json:"name" bun:"name,type:VARCHAR(100),notnull"`
	ParentBusinessUnit   *BusinessUnit `json:"parentBusinessUnit" bun:"rel:belongs-to,join:parent_business_unit_id=id"`
}

type Organization struct {
	bun.BaseModel  `bun:"table:organizations,alias:org" json:"-"`
	ID             pulid.ID      `json:"id" bun:",pk,type:VARCHAR(100)"`
	BusinessUnitID pulid.ID      `json:"businessUnitId" bun:"business_unit_id,type:VARCHAR(100),notnull"`
	StateID        pulid.ID      `json:"stateId" bun:"state_id,type:VARCHAR(100),notnull"`
	Name           string        `json:"name" bun:"name,type:VARCHAR(100),notnull"`
	BusinessUnit   *BusinessUnit `json:"businessUnit,omitempty" bun:"rel:belongs-to,join:business_unit_id=id"`
}

// models.go
type User struct {
	bun.BaseModel         `bun:"table:users,alias:usr" json:"-"`
	ID                    pulid.ID `json:"id" bun:"id,pk,type:VARCHAR(100)"`
	BusinessUnitID        pulid.ID `json:"businessUnitId" bun:"business_unit_id,type:VARCHAR(100),notnull"`
	CurrentOrganizationID pulid.ID `json:"currentOrganizationId" bun:"current_organization_id,type:VARCHAR(100),notnull"`

	Status              string          `json:"status" bun:"status,type:VARCHAR(100),notnull,default:'Active'"`
	Name                string          `json:"name" bun:"name,type:VARCHAR(255),notnull"`
	Username            string          `json:"username" bun:"username,type:VARCHAR(20),notnull"`
	BusinessUnit        *BusinessUnit   `json:"-" bun:"rel:belongs-to,join:business_unit_id=id"`
	CurrentOrganization *Organization   `json:"currentOrganization,omitempty" bun:"rel:belongs-to,join:current_organization_id=id"`
	Organizations       []*Organization `json:"organizations,omitempty" bun:"m2m:user_organizations,join:User=Organization"`
	Roles               []*Role         `json:"roles,omitempty" bun:"m2m:user_roles,join:User=Role"`
}

type UserRole struct {
	bun.BaseModel  `bun:"table:user_roles,alias:ur" json:"-"`
	BusinessUnitID pulid.ID `json:"businessUnitId" bun:"business_unit_id,pk,type:VARCHAR(100),notnull"`
	OrganizationID pulid.ID `json:"organizationId" bun:"organization_id,pk,type:VARCHAR(100),notnull"`
	UserID         pulid.ID `json:"userId" bun:"user_id,pk,type:VARCHAR(100),notnull"`
	RoleID         pulid.ID `json:"roleId" bun:"role_id,pk,type:VARCHAR(100),notnull"`
	User           *User    `json:"-" bun:"rel:belongs-to,join:user_id=id"`
	Role           *Role    `json:"-" bun:"rel:belongs-to,join:role_id=id"`
}

type Role struct {
	bun.BaseModel  `bun:"table:roles,alias:r" json:"-"`
	ID             pulid.ID      `json:"id" bun:",pk,type:VARCHAR(100)"`
	Name           string        `json:"name" bun:"name,type:VARCHAR(100),notnull"`
	Description    string        `json:"description" bun:"description,type:TEXT"`
	BusinessUnitID pulid.ID      `json:"businessUnitId" bun:"business_unit_id,type:VARCHAR(100)"`
	OrganizationID pulid.ID      `json:"organizationId" bun:"organization_id,type:VARCHAR(100)"`
	ParentRoleID   *pulid.ID     `json:"parentRoleId,omitempty" bun:"parent_role_id,type:VARCHAR(100),nullzero"`
	Permissions    []*Permission `json:"permissions,omitempty" bun:"m2m:role_permissions,join:Role=Permission"`
	ParentRole     *Role         `json:"parentRole,omitempty" bun:"rel:belongs-to,join:parent_role_id=id"`
	ChildRoles     []*Role       `json:"childRoles,omitempty" bun:"rel:has-many,join:id=parent_role_id"`
}

type RolePermission struct {
	bun.BaseModel  `bun:"table:role_permissions,alias:rp" json:"-"`
	BusinessUnitID pulid.ID    `json:"businessUnitId" bun:"business_unit_id,pk,type:VARCHAR(100),notnull"`
	OrganizationID pulid.ID    `json:"organizationId" bun:"organization_id,pk,type:VARCHAR(100),notnull"`
	RoleID         pulid.ID    `json:"roleId" bun:"role_id,pk,type:VARCHAR(100),notnull"`
	PermissionID   pulid.ID    `json:"permissionId" bun:"permission_id,pk,type:VARCHAR(100),notnull"`
	Role           *Role       `json:"-" bun:"rel:belongs-to,join:role_id=id"`
	Permission     *Permission `json:"-" bun:"rel:belongs-to,join:permission_id=id"`
}

type Permission struct {
	bun.BaseModel `bun:"table:permissions,alias:p" json:"-"`
	ID            pulid.ID `bun:",pk,type:VARCHAR(100)"`
}

type UserOrganization struct {
	bun.BaseModel  `bun:"table:user_organizations,alias:uo" json:"-"`
	UserID         pulid.ID      `json:"userId" bun:",pk,type:VARCHAR(100)"`
	OrganizationID pulid.ID      `json:"organizationId" bun:",pk,type:VARCHAR(100)"`
	CreatedAt      int64         `json:"createdAt" bun:"created_at,type:BIGINT,nullzero,notnull,default:extract(epoch from current_timestamp)::bigint"`
	User           *User         `json:"user,omitempty" bun:"rel:belongs-to,join:user_id=id"`
	Organization   *Organization `json:"organization,omitempty" bun:"rel:belongs-to,join:organization_id=id"`
}

func RegisterEntities() []any {
	return []any{
		&BusinessUnit{},
		&Organization{},
		&RolePermission{},
		&Permission{},
		&Role{},
		&UserRole{},
		&UserOrganization{},
		&User{},
	}
}

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

	// sqldb := sql.OpenDB(pgdriver.NewConnector(pgdriver.WithDSN("postgres://bun:bun@localhost:5433/bun?sslmode=disable")))
	cfg, err := pgxpool.ParseConfig("postgres://bun:bun@localhost:5432/bun?sslmode=disable")
	if err != nil {
		panic(err)
	}
	cfg.ConnConfig.DefaultQueryExecMode = pgx.QueryExecModeSimpleProtocol

	pool, err := pgxpool.NewWithConfig(ctx, cfg)
	if err != nil {
		panic(err)
	}

	sqldb := stdlib.OpenDBFromPool(pool)

	db := bun.NewDB(sqldb, pgdialect.New())
	db.AddQueryHook(bundebug.NewQueryHook(
		bundebug.WithVerbose(true),
		bundebug.FromEnv("BUNDEBUG"),
	))

	db.RegisterModel(RegisterEntities()...)
	if err := db.ResetModel(ctx, RegisterEntities()...); err != nil {
		panic(err)
	}

	OrgID := pulid.MustNew("orga")
	BuID := pulid.MustNew("bua")
	UserID := pulid.MustNew("ua")
	RoleID := pulid.MustNew("ra")

	{
		bu := BusinessUnit{
			ID:   BuID,
			Name: "bua",
		}
		if _, err := db.NewInsert().Model(&bu).Exec(ctx); err != nil {
			panic(err)
		}
		org := Organization{
			ID:             OrgID,
			BusinessUnitID: BuID,
			Name:           "orga",
		}
		if _, err := db.NewInsert().Model(&org).Exec(ctx); err != nil {
			panic(err)
		}
		user := User{
			ID:                    UserID,
			BusinessUnitID:        BuID,
			CurrentOrganizationID: OrgID,
		}
		if _, err := db.NewInsert().Model(&user).Exec(ctx); err != nil {
			panic(err)
		}
		role := Role{
			ID:             RoleID,
			Name:           "ra",
			BusinessUnitID: BuID,
			OrganizationID: OrgID,
		}
		if _, err := db.NewInsert().Model(&role).Exec(ctx); err != nil {
			panic(err)
		}
		ur := UserRole{
			UserID:         UserID,
			RoleID:         RoleID,
			BusinessUnitID: BuID,
			OrganizationID: OrgID,
		}
		if _, err := db.NewInsert().Model(&ur).Exec(ctx); err != nil {
			panic(err)
		}
	}

	{
		user := User{}
		if err := db.NewSelect().
			Model(&user).
			Where("usr.id = ?", UserID).
			Relation("Roles").
			Relation("Roles.Permissions").
			Scan(ctx); err != nil {
			panic(err)
		}
		fmt.Println(user)
	}
	{
		user := User{}
		if err := db.NewSelect().
			Model(&user).
			Where("usr.id = ?", UserID).
			Relation("Roles").
			Relation("Roles.Permissions").
			Scan(ctx); err != nil {
			panic(err)
		}
		fmt.Println(user)
	}
}

@j2gg0s
Copy link
Collaborator

j2gg0s commented Jan 5, 2025

Thank you for helping to identify the issue. I believe your judgment is correct.

@emoss08
Copy link
Contributor Author

emoss08 commented Jan 5, 2025

Thank you for going down this rabbit hole with me and also submitting the PR to fix. Really appreciate your responses to this issue.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging a pull request may close this issue.

2 participants