The data
package is designed to provide a consistent programming model for data access regardless of the underlying data store.
It contains sub packages that are specific to given database. Postgresql and CockroachDB are the databases that are supported by default.
Application can enable other databases as long as they are supported by Gorm.
To use this package, include the following code snippet in your application. With these two lines, go-lanai will instantiate all the components provided in the data package, as well as the components specifically for CockroachDB
data.Use()
cockroach.Use()
Add the following section in application.yml
. These are the connection parameters to your database.
data:
logging:
level: warn
slow-threshold: 5s
db:
host: localhost
port: 26257
sslmode: disable
username: my_user_name
Password: my_password
database: my_db_name
Define your database model. The following example is a model for a database table called friend
that has three columns id
, first_name
and last_name
type Friend struct {
ID uuid.UUID `gorm:"column:id;primary_key;type:UUID;default:gen_random_uuid();"`
FirstName string `gorm:"column:first_name;type:text;not null;"`
LastName string `gorm:"column:last_name;type:text;not null;"`
}
Declare a repository for this model. The repo.GormApi
interface allows you to write low level database queries.
The repo.CrudRepository
has convenient methods for CRUD operations.
type FriendsRepository struct {
repo.GormApi
repo.CrudRepository
}
func NewFriendRepository(factory repo.Factory) *FriendsRepository {
crud := factory.NewCRUD(&model.Friend{})
ret := FriendsRepository{
CrudRepository: crud,
}
if gf, ok := factory.(*repo.GormFactory); ok {
ret.GormApi = gf.NewGormApi()
}
return &ret
}
Make this repository available for dependency injection, and you will be able to use it in your code. (Call this Use()
function
in your setup code so that it's available for injection).
func Use() {
bootstrap.AddOptions(
fx.Provide(
NewFriendRepository,
),
)
}
The CrudRepository
interface is an abstraction that defines the most commonly used data access operations such as Create,
Read, Update, Delete. go-lanai
provides implementation for these methods, so they don't have to be repeated in
application code. This is all that is required to instantiate a CrudRepository
for a model.
type FriendRepository CrudRepository
func NewFriendRepository(factory Factory) FriendRepository {
return factory.NewCRUD(&model.Friend{})
}
Most CrudRepository
method takes Condition
and Options
. Condition
s are conditional statements that are appended to the query,
Options
defines how the query should be processed. For example, a query to return all the friends whose first name is John by page can
be written with a condition and option.
var friends []model.Friend
err = r.FindAllBy(
ctx,
&friends,
&model.Friend{FirstName: "John"},
repo.Page(pageNumber, pageSize),
)
Sometimes application have data access logic that are beyond the CRUD operations. For these situations, developer can work directly with the lower level gorm API.
api := factory.NewGormApi(options...)
Error originating from the database driver are mapped to hierarchical DataError
. Application code can compare the error
they received to the errors defined in the error hierarchy to inspect the error case.
go-lanai
also uses this error hierarchy to translate the data access error to web status code, so that if application code
returned the error directly as web response the http response status will be correct.
This is how the error handler translate the status code using the error hierarchy. Application code can also use similar technique to inspect the error case.
func (t WebDataErrorTranslator) Translate(ctx context.Context, err error) error {
//nolint:errorlint
if _, ok := err.(errorutils.ErrorCoder); !ok || !errors.Is(err, ErrorCategoryData) {
return err
}
switch {
case errors.Is(err, ErrorRecordNotFound), errors.Is(err, ErrorIncorrectRecordCount):
return t.errorWithStatusCode(ctx, err, http.StatusNotFound)
case errors.Is(err, ErrorSubTypeDataIntegrity):
return t.errorWithStatusCode(ctx, err, http.StatusConflict)
case errors.Is(err, ErrorSubTypeQuery):
return t.errorWithStatusCode(ctx, err, http.StatusBadRequest)
case errors.Is(err, ErrorSubTypeTimeout):
return t.errorWithStatusCode(ctx, err, http.StatusRequestTimeout)
case errors.Is(err, ErrorTypeTransient):
return t.errorWithStatusCode(ctx, err, http.StatusServiceUnavailable)
default:
return t.errorWithStatusCode(ctx, err, http.StatusInternalServerError)
}
}
The tx
package provides two ways for application code that requires transaction. The func Transaction(ctx context.Context, tx TxFunc, opts ...*sql.TxOptions) error
function allows application code to provide a function that will be run within a transaction. If this function returns error, any database
operation issued within this function will be rolled back. Otherwise, results will be committed.
In this example, if the second operation failed, the first operation will be rolled back.
e = tx.Transaction(ctx, func(ctx context.Context) (err error) {
// first operation
firstFriend := model.Friend{firstName:"John", lastName:"Smith"}
err = di.Repo.Create(ctx, firstFriend)
if err != nil {
return err
}
// second operation
another := model.Friend{firstName:"Jane", lastName:"Doe"}
err = di.Repo.Create(ctx, another)
return err
})
Alternatively, application code can also handle transaction manually using the following set of methods.
// Begin start a transaction. the returned context.Context should be used for any transactional operations.
// If an error is returned, the returned context.Context should be discarded.
func Begin(ctx context.Context, opts ...*sql.TxOptions) (context.Context, error)
// Rollback rollbacks a transaction. The returned context.Context is the original provided context when Begin is called.
// If an error is returned, the returned context.Context should be discarded.
func Rollback(ctx context.Context) (context.Context, error)
// Commit commits a transaction. the returned context.Context is the original provided context when Begin is called.
// If an error is returned, the returned context.Context should be discarded.
func Commit(ctx context.Context) (context.Context, error)
// SavePoint works with RollbackTo and have to be within a transaction.
// The returned context.Context should be used for any transactional operations between corresponding SavePoint and RollbackTo.
// If an error is returned, the returned context.Context should be discarded.
func SavePoint(ctx context.Context, name string) (context.Context, error)
// RollbackTo works with SavePoint and have to be within a transaction.
// The returned context.Context should be used for any transactional operations between corresponding SavePoint and RollbackTo.
// If an error is returned, the returned context.Context should be discarded.
func RollbackTo(ctx context.Context, name string) (context.Context, error)
EncryptedMap
is useful when certain aspect of the data needs to be encrypted. The encryption is backed by Vault
transit secret engine.
The following snippet declares a model that has encrypted data.
type EncryptedModel struct {
ID int `gorm:"primaryKey;type:serial;"`
Name string `gorm:"uniqueIndex;not null;"`
Value *EncryptedMap
}
Saving to the database is the same as any other model.
v := map[string]interface{}{
"key1": "value1",
"key2": 2.0,
}
kid := uuid.New()
pqcrypt.CreateKeyWithUUID(ctx, kid)
m := EncryptedModel{
ID: 12345678,
Name: "my_encrypted_model",
Value: NewEncryptedMap(kid, v),
}
myRepo.Save(ctx, &m)
Reading from the database will decrypt the data.
m := EncryptedModel{}
myRepo.FindById(ctx, &m, 12345678) // m's Value field will have the decrypted map
If a model embeds the Tenancy
type. This model gets two fields that facilitates multi tenant implementation. The TenantId
column
will store the tenant ID of this record. The TenantPath
column will store the path from the Tenant ID to the root tenant if
there is a hierarchical tenant relationship. Database operations on this model will automatically take tenancy into consideration
based on the current security context.
// Tenancy is an embedded type for data model. It's responsible for populating TenantPath and check for Tenancy related data
// when crating/updating. Tenancy implements
// - callbacks.BeforeCreateInterface
// - callbacks.BeforeUpdateInterface
// When used as an embedded type, tag `filter` can be used to override default tenancy check behavior:
// - `filter:"w"`: create/update/delete are enforced (Default mode)
// - `filter:"rw"`: CRUD operations are all enforced,
// this mode filters result of any Select/Update/Delete query based on current security context
// - `filter:"-"`: filtering is disabled. Note: setting TenantID to in-accessible tenant is still enforced.
// to disable TenantID value check, use SkipTenancyCheck
// e.g.
// <code>
// type TenancyModel struct {
// ID uuid.UUID `gorm:"primaryKey;type:uuid;default:gen_random_uuid();"`
// Tenancy `filter:"rw"`
// }
// </code>
type Tenancy struct {
TenantID uuid.UUID `gorm:"type:KeyID;not null"`
TenantPath TenantPath `gorm:"type:uuid[];index:,type:gin;not null" json:"-"`
}
These models are provided as convenient types that can be embedded in application model.
type Audit struct {
CreatedAt time.Time `json:"createdAt,omitempty"`
UpdatedAt time.Time `json:"updatedAt,omitempty"`
CreatedBy uuid.UUID `type:"KeyID;" json:"createdBy,omitempty"`
UpdatedBy uuid.UUID `type:"KeyID;" json:"updatedBy,omitempty"`
}
type SoftDelete struct {
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleteAt,omitempty"`
}
In addition, check the pqx
package for common data types such as Duration
, Jsonb
, TimeArray
, UUIDArray