Documentation
¶
Overview ¶
Package repo is a generic, tenant-scoped repository over GORM.
It exists for one reason above all the convenience: the tenant filter is applied by the repository, not by the caller.
Hand-written scoping means every query in the codebase has to remember `.Where("account_id = ?", tenant)`, and the one that forgets does not fail — it returns another customer's rows, silently, forever, until somebody notices. Here, a tenant-scoped repository applies the filter itself and REFUSES a request with no tenant on the context rather than quietly running unscoped. The unsafe operation still exists, spelled Unscoped, because reconcilers genuinely need it; it just cannot happen by omission.
type ProductRepo struct{ *repo.Repository[entity.Product] }
func NewProductRepo(db *gorm.DB) *ProductRepo {
return &ProductRepo{repo.New[entity.Product](db,
repo.WithEntityName("product"),
repo.WithTenantColumn("account_id"),
)}
}
Index ¶
- Constants
- func InTx(ctx context.Context) bool
- func Transaction(ctx context.Context, db *gorm.DB, fn func(ctx context.Context) error) error
- func TxFrom(ctx context.Context, fallback *gorm.DB) *gorm.DB
- func WithTx(ctx context.Context, tx *gorm.DB) context.Context
- type Option
- type Page
- type Repository
- func (r *Repository[T]) All(ctx context.Context, scopes ...gormx.Scope) ([]T, error)
- func (r *Repository[T]) Count(ctx context.Context, scopes ...gormx.Scope) (int64, error)
- func (r *Repository[T]) Create(ctx context.Context, model *T) error
- func (r *Repository[T]) CreateMany(ctx context.Context, models []T) error
- func (r *Repository[T]) DB(ctx context.Context) *gorm.DB
- func (r *Repository[T]) Delete(ctx context.Context, scopes ...gormx.Scope) (int64, error)
- func (r *Repository[T]) DeleteByID(ctx context.Context, id uuid.UUID) error
- func (r *Repository[T]) Exists(ctx context.Context, scopes ...gormx.Scope) (bool, error)
- func (r *Repository[T]) FindByID(ctx context.Context, id uuid.UUID, scopes ...gormx.Scope) (*T, error)
- func (r *Repository[T]) FindOne(ctx context.Context, scopes ...gormx.Scope) (*T, error)
- func (r *Repository[T]) List(ctx context.Context, q pagination.Query, scopes ...gormx.Scope) (Page[T], error)
- func (r *Repository[T]) LockByID(ctx context.Context, id uuid.UUID) (*T, error)
- func (r *Repository[T]) Save(ctx context.Context, model *T) error
- func (r *Repository[T]) Scoped(ctx context.Context) (*gorm.DB, error)
- func (r *Repository[T]) Sortable() []string
- func (r *Repository[T]) Unscoped(ctx context.Context) *gorm.DB
- func (r *Repository[T]) UpdateByID(ctx context.Context, id uuid.UUID, fields map[string]any) error
- func (r *Repository[T]) UpdateFields(ctx context.Context, fields map[string]any, scopes ...gormx.Scope) (int64, error)
- type TxManager
Constants ¶
const DefaultTenantColumn = "account_id"
DefaultTenantColumn is the column a tenant-scoped repository filters on.
Variables ¶
This section is empty.
Functions ¶
func Transaction ¶
Transaction runs fn inside one database transaction.
fn receives a context carrying the transaction, so every repository call it makes joins that transaction automatically. Return an error and everything rolls back; return nil and it commits.
Nesting is handled by JOINING the outer transaction rather than opening a savepoint. That is the behaviour a service wants: when a charge inside a provisioning transaction fails, the resource row must roll back with it, and a savepoint would let the outer work commit without the charge.
func TxFrom ¶
TxFrom returns the transaction on ctx, or fallback when there is none.
Every repository method routes through this, which is what makes transactions composable: a service calls three repositories inside one Transaction block and all three join it, without any of them taking a *gorm.DB parameter or knowing a transaction exists.
Types ¶
type Option ¶
type Option func(cfg *config)
Option configures a Repository.
func WithEntityName ¶
WithEntityName sets the noun used in error messages: "product", "invoice". Without it, errors say "record", which is true and useless.
func WithSortable ¶
WithSortable sets the columns List will accept a client sort on.
func WithTenantColumn ¶
WithTenantColumn makes the repository tenant-scoped on that column.
Setting it changes the safety contract: every method then REQUIRES a tenant on the context and returns an error without one.
type Repository ¶
type Repository[T any] struct { // contains filtered or unexported fields }
Repository is the data-access surface for one entity type.
func (*Repository[T]) All ¶
All returns every matching row, unpaginated.
Use it only where the result set is bounded by design — the regions of a platform, the rules of one security group. For anything a tenant can grow without limit, use List; "it is only a few hundred rows" is a statement about today.
func (*Repository[T]) Create ¶
func (r *Repository[T]) Create(ctx context.Context, model *T) error
Create inserts one row.
It sets the tenant column from the context when the repository is scoped, so a caller cannot insert a row under another account by setting the field — the context wins over the payload, always.
func (*Repository[T]) CreateMany ¶
func (r *Repository[T]) CreateMany(ctx context.Context, models []T) error
CreateMany inserts in batches of 200.
func (*Repository[T]) DB ¶
func (r *Repository[T]) DB(ctx context.Context) *gorm.DB
DB returns the session bound to ctx and to the transaction on it, if any.
Use it for a query this repository does not cover. It is NOT tenant-scoped — call Scoped for that.
func (*Repository[T]) Delete ¶
Delete removes rows matching the scopes (a soft delete when the entity has gorm.DeletedAt). It refuses an empty scope list for the same reason UpdateFields does.
func (*Repository[T]) DeleteByID ¶
DeleteByID removes one row, reporting NotFound when nothing matched.
func (*Repository[T]) FindByID ¶
func (r *Repository[T]) FindByID(ctx context.Context, id uuid.UUID, scopes ...gormx.Scope) (*T, error)
FindByID returns one row by primary key, scoped to the tenant.
A row belonging to another account returns NotFound, not Forbidden: Forbidden would confirm the id exists to someone who should not know it.
func (*Repository[T]) List ¶
func (r *Repository[T]) List(ctx context.Context, q pagination.Query, scopes ...gormx.Scope) (Page[T], error)
List returns a page of rows and the unpaginated total.
It runs the COUNT and the SELECT as two queries against the same scopes. That is two round trips rather than one window function — the trade is deliberate: the window-function form breaks the moment a scope adds a JOIN that multiplies rows, and a wrong total is worse than a second query.
func (*Repository[T]) LockByID ¶
LockByID loads one row FOR UPDATE. It must be called inside a transaction; a row lock outside one is released immediately and protects nothing.
func (*Repository[T]) Save ¶
func (r *Repository[T]) Save(ctx context.Context, model *T) error
Save writes every field of model, including the zero ones.
func (*Repository[T]) Scoped ¶
Scoped returns the session with the tenant filter applied, or an error when the repository is tenant-scoped and the context carries no tenant.
func (*Repository[T]) Sortable ¶
func (r *Repository[T]) Sortable() []string
Sortable returns the allow-list, for a controller to pass to the paginator.
func (*Repository[T]) Unscoped ¶
func (r *Repository[T]) Unscoped(ctx context.Context) *gorm.DB
Unscoped returns the session WITHOUT the tenant filter.
Legitimate callers: a reconciler sweeping every account, a platform report, an admin tool. Every use is a deliberate crossing of the tenancy boundary — make it obvious at the call site and keep it out of request paths.
func (*Repository[T]) UpdateByID ¶
UpdateByID applies a column→value map to one row and reports NotFound when nothing matched — which covers both "no such id" and "not yours".
func (*Repository[T]) UpdateFields ¶
func (r *Repository[T]) UpdateFields(ctx context.Context, fields map[string]any, scopes ...gormx.Scope) (int64, error)
UpdateFields applies a column→value map to the rows matching the scopes, within the tenant.
A map, not a struct, because GORM skips zero-valued struct fields: updating with a struct can never set a column to false, 0 or "". That silent skip is a recurring production defect — a PATCH that disables something appears to succeed and changes nothing.
It REFUSES an empty scope list, because an update with no WHERE rewrites every row the tenant owns.