Database & ORM
Homebox uses a two-layer database architecture: Ent ORM for schema definition and queries, and Goose migrations for schema synchronization across multiple database engines.
Homebox supports two different database engines:
- SQLite3 - Default, lightweight, bundled with the application
- PostgreSQL - Production-grade, enterprise-ready
This means we maintain separate migration files for each database engine to ensure compatibility.
Ent ORM
Section titled “Ent ORM”Ent is a modern Go ORM that provides type-safe database access through code generation. All database entities are defined in schema files that automatically generate query builders, mutations, and type-safe accessors.
Directory Structure
Section titled “Directory Structure”Directorybackend/
Directoryinternal/
Directorydata/
Directoryent/
Directoryschema/
Directorymixins/
- base_mixin.go (common fields like ID, createdAt, updatedAt)
- details_mixin.go (description, name fields)
- …other mixins…
- item.go (Item entity definition)
- label.go (Label entity definition)
- location.go (Location entity definition)
- user.go (User entity definition)
- group.go (Group entity definition)
- …other entity schemas…
Directory(auto-generated files)
- item.go
- item_create.go
- item_update.go
- item_query.go
- …other auto-generated files…
- client.go (database client)
Directorymigrate/ (schema migration hooks)
- …
Directoryrepo/
- repos_all.go (repository container)
- repo_items.go (Items repository)
- repo_labels.go (Labels repository)
- …other repositories…
Entity Schemas
Section titled “Entity Schemas”Entity schemas define the database structure, fields, edges (relationships), and indexes. They live in backend/internal/data/ent/schema/.
Simple Entity Example
Section titled “Simple Entity Example”package schema
import ( "entgo.io/ent" "entgo.io/ent/schema/edge" "entgo.io/ent/schema/field")
// Label holds the schema definition for the Label entity.type Label struct { ent.Schema}
func (Label) Mixin() []ent.Mixin { return []ent.Mixin{ mixins.BaseMixin{}, // Adds ID, CreatedAt, UpdatedAt mixins.DetailsMixin{}, // Adds Name, Description GroupMixin{ref: "labels"}, // Multi-tenancy support }}
// Fields of the Label.func (Label) Fields() []ent.Field { return []ent.Field{ field.String("color"). MaxLen(255). Optional(), }}
// Edges of the Label.func (Label) Edges() []ent.Edge { return []ent.Edge{ edge.To("items", Item.Type), }}Complex Entity Example with Indexes
Section titled “Complex Entity Example with Indexes”package schema
import ( "entgo.io/ent" "entgo.io/ent/schema/edge" "entgo.io/ent/schema/field" "entgo.io/ent/schema/index")
type Item struct { ent.Schema}
func (Item) Mixin() []ent.Mixin { return []ent.Mixin{ mixins.BaseMixin{}, mixins.DetailsMixin{}, GroupMixin{ref: "items"}, }}
// Add database indexes for frequently queried fieldsfunc (Item) Indexes() []ent.Index { return []ent.Index{ index.Fields("name"), index.Fields("serial_number"), index.Fields("asset_id"), index.Fields("archived"), }}
func (Item) Fields() []ent.Field { return []ent.Field{ field.String("serial_number"). MaxLen(255). Optional(), field.String("model_number"). MaxLen(255). Optional(), field.Int("quantity"). Default(1), field.Bool("insured"). Default(false), field.Bool("archived"). Default(false), field.Int("asset_id"). Default(0), }}
func (Item) Edges() []ent.Edge { return []ent.Edge{ edge.From("group", Group.Type). Ref("items"). Required(). OnDelete(ent.Cascade), edge.From("location", Location.Type). Ref("items"), edge.To("labels", Label.Type), edge.To("attachments", Attachment.Type), edge.To("fields", ItemField.Type), }}Field Types
Section titled “Field Types”Common ent field types and their usage:
| Type | Method | Notes |
|---|---|---|
| String | field.String() | Use .MaxLen() to set database constraints |
| Integer | field.Int() or field.Int64() | Use .Default() for default values |
| Boolean | field.Bool() | Use .Default() for default values |
| Float | field.Float() or field.Float64() | Decimals and floating point numbers |
| Time | field.Time() | Use .Default(time.Now) for timestamps |
| UUID | field.UUID() | Requires google/uuid package |
| JSON | field.JSON() | Flexible JSON storage |
| Enum | field.Enum() | Strongly-typed enumerations |
Edges (Relationships)
Section titled “Edges (Relationships)”Edges define relationships between entities:
// One-to-Many relationshipedge.To("items", Item.Type) // Forward edgeedge.From("items", Item.Type).Ref("location") // Backward reference
// Required relationship with cascade deleteedge.From("group", Group.Type). Ref("items"). Required(). OnDelete(ent.Cascade)
// Optional relationshipedge.To("labels", Label.Type)Mixins
Section titled “Mixins”Mixins reduce boilerplate by sharing common fields across multiple entities:
// BaseMixin adds ID, CreatedAt, UpdatedAt to all entitiestype BaseMixin struct { mixin.Schema}
func (BaseMixin) Fields() []ent.Field { return []ent.Field{ field.UUID("id", uuid.UUID{}). Default(uuid.New). StorageKey("id"). Immutable(), field.Time("created_at"). Default(time.Now). Immutable(), field.Time("updated_at"). Default(time.Now). UpdateDefault(time.Now), }}After Modifying Schemas
Section titled “After Modifying Schemas”Querying with Ent
Section titled “Querying with Ent”All ent queries go through the generated query builders. Repositories wrap these for cleaner APIs:
// Get a single user by IDuser, err := client.User.Get(ctx, userID)
// Get all items for a group with relationshipsitems, err := client.Item.Query(). Where(item.GroupID(groupID)). WithLabels(). WithLocation(). All(ctx)
// Count itemscount, err := client.Item.Query(). Where(item.GroupID(groupID)). Count(ctx)
// Query with filtersitems, err := client.Item.Query(). Where( item.And( item.GroupID(groupID), item.Archived(false), item.NameContains("search term"), ), ). All(ctx)
// Paginationitems, err := client.Item.Query(). Where(item.GroupID(groupID)). Offset(page * pageSize). Limit(pageSize). All(ctx)// Repositories wrap ent queries for cleaner APItype ItemsRepository struct { db *ent.Client}
func (r *ItemsRepository) GetOneByGroup(ctx context.Context, groupID, itemID uuid.UUID) (ItemOut, error) { item, err := r.db.Item.Query(). Where( item.And( item.GroupID(groupID), item.ID(itemID), ), ). WithLabels(). WithLocation(). Only(ctx)
if err != nil { if ent.IsNotFound(err) { return ItemOut{}, nil // Return zero value for not found } return ItemOut{}, err }
return mapItemOut(item), nil}Creating & Updating
Section titled “Creating & Updating”Ent provides type-safe builders for mutations:
item, err := client.Item.Create(). SetName("Laptop"). SetQuantity(1). SetArchived(false). SetGroupID(groupID). SetLocationID(locationID). AddLabelIDs(labelIDs...). Save(ctx)item, err := client.Item.UpdateOneID(itemID). SetName("Updated Name"). SetQuantity(2). Save(ctx)err := client.Item.DeleteOneID(itemID).Exec(ctx)Database Migrations
Section titled “Database Migrations”Goose manages schema migrations. Migrations are version-controlled and executed automatically when the server starts.
Migration Structure
Section titled “Migration Structure”Migrations live in backend/internal/data/migrations with separate subdirectories for each database engine:
Directorybackend/
Directoryinternal/
Directorydata/
Directorymigrations/
Directorypostgres/
- 20220929052825_init.sql
- 20241226183416_sync_children.go
- …
Directorysqlite3/
- 20220929052821_init.sql
- 20241226183418_sync_children.go
- …
General Migration Best Practices
Section titled “General Migration Best Practices”- Naming Convention - Use format
YYYYMMDDHHMMSS_description.ext - Dual Engine Support - Create migrations for both PostgreSQL and SQLite3 unless change is engine-specific
- Data Preservation - Never use migrations that cause data loss
- Data Migration - Use explicit column names:
INSERT INTO table SELECT column1, column2 FROM table2(neverSELECT *) - Unique Versions - Migration versions must be unique globally across both engine directories (Goose limitation)
- No Down Migrations - We don’t use reversible migrations; all are one-way
- Thorough Testing - Test migrations on both database engines before merging
SQL Migrations
Section titled “SQL Migrations”SQL migrations are simple SQL files with -- +goose Up annotations. They’re the preferred approach for most schema changes.
Structure
Section titled “Structure”-- +goose Up-- +goose StatementBeginCREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, email TEXT UNIQUE NOT NULL, name TEXT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP);-- +goose StatementEnd
-- +goose Down-- This migration cannot be reversedSQLite3 Special Considerations
Section titled “SQLite3 Special Considerations”SQLite3 has limited ALTER TABLE support, requiring more boilerplate:
PostgreSQL Migrations
Section titled “PostgreSQL Migrations”PostgreSQL migrations are generally straightforward with full ALTER TABLE support:
-- +goose UpCREATE TABLE IF NOT EXISTS users ( id uuid PRIMARY KEY, email TEXT UNIQUE NOT NULL, name TEXT NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT NOW(), updated_at TIMESTAMP NOT NULL DEFAULT NOW());
-- +goose Down-- (cannot reverse)Go Migrations
Section titled “Go Migrations”Go migrations are used for complex operations (data transformations, conditional logic). They’re more powerful but harder to maintain than SQL migrations.
Structure
Section titled “Structure”package sqlite3
import ( "context" "database/sql" "fmt"
"github.com/pressly/goose/v3")
//nolint:gochecknoinitsfunc init() { goose.AddMigrationContext(Up20241226183418, Down20241226183418)}
func Up20241226183418(ctx context.Context, tx *sql.Tx) error { // Check if the column already exists columnName := "sync_child_items_locations" query := ` SELECT name FROM pragma_table_info('items') WHERE name = 'sync_child_items_locations'; ` err := tx.QueryRowContext(ctx, query).Scan(&columnName) if err == nil { // Column already exists, skip migration return nil }
// Add the column if it doesn't exist _, err = tx.ExecContext(ctx, ` ALTER TABLE items ADD COLUMN sync_child_items_locations BOOLEAN DEFAULT FALSE; `) if err != nil { return fmt.Errorf("failed to add column: %w", err) }
return nil}
func Down20241226183418(ctx context.Context, tx *sql.Tx) error { // This migration is a no-op for SQLite (cannot reverse) return nil}Adding a New Entity
Section titled “Adding a New Entity”-
Create the Schema File
Create a new file in
backend/internal/data/ent/schema/:backend/internal/data/ent/schema/myentity.go package schemaimport ("entgo.io/ent""entgo.io/ent/schema/edge""entgo.io/ent/schema/field")type MyEntity struct {ent.Schema}func (MyEntity) Mixin() []ent.Mixin {return []ent.Mixin{mixins.BaseMixin{},mixins.DetailsMixin{},GroupMixin{ref: "myentities"},}}func (MyEntity) Fields() []ent.Field {return []ent.Field{field.String("custom_field").MaxLen(255),}}func (MyEntity) Edges() []ent.Edge {return []ent.Edge{edge.From("group", Group.Type).Ref("myentities").Required().OnDelete(ent.Cascade),}} -
Generate Ent Code
Terminal window task generateThis will:
- Generate all
*_create.go,*_update.go,*_query.gofiles - Update TypeScript types for the frontend
- Generate all
-
Review Generated Migrations
Create the appropriate migrations for both SQLite and Postgres based upon your changes to the schema.
-
Create Repository
Create
backend/internal/data/repo/repo_myentities.go:package repoimport ("context""github.com/google/uuid""github.com/sysadminsmedia/homebox/backend/internal/data/ent""github.com/sysadminsmedia/homebox/backend/internal/data/ent/myentity")type MyEntitiesRepository struct {db *ent.Client}func (r *MyEntitiesRepository) GetAll(ctx context.Context, groupID uuid.UUID) ([]MyEntitySummary, error) {entities, err := r.db.MyEntity.Query().Where(myentity.GroupID(groupID)).All(ctx)if err != nil {return nil, err}return mapMyEntitySummaries(entities), nil} -
Register Repository
Add to
backend/internal/data/repo/repos_all.go:type AllRepos struct {// ... existing fields ...MyEntities *MyEntitiesRepository}func New(db *ent.Client, ...) *AllRepos {return &AllRepos{// ... existing repos ...MyEntities: &MyEntitiesRepository{db},}} -
Create Handlers
Add handlers in
backend/app/api/handlers/v1/as documented in the API Handlers guide. -
Test
Terminal window task go:test
Common Patterns
Section titled “Common Patterns”Multi-Tenancy (Group Scoping)
Section titled “Multi-Tenancy (Group Scoping)”All entities use the GroupMixin to ensure data isolation between groups:
// Always scope queries to the user's groupitems, err := repo.Items.QueryByGroup(ctx, auth.GID, query)
// This is enforced in the repository layerfunc (r *ItemsRepository) GetAll(ctx context.Context, groupID uuid.UUID) ([]ItemOut, error) { items, err := r.db.Item.Query(). Where(item.GroupID(groupID)). // ← ALWAYS filter by groupID All(ctx) // ...}Soft Deletes
Section titled “Soft Deletes”Some entities support soft deletes with an archived field instead of hard deletion:
// Mark as archived instead of deletingitem, err := repo.Items.Update(ctx, ItemUpdate{ ID: itemID, Archived: true,})
// Query excludes archived by defaultitems, err := repo.Items.GetAll(ctx, groupID)Cascading Deletes
Section titled “Cascading Deletes”Parent-child relationships use OnDelete(ent.Cascade):
edge.From("group", Group.Type). Ref("items"). Required(). OnDelete(ent.Cascade), // ← Deleting group deletes all itemsUseful Commands
Section titled “Useful Commands”task generate # Regenerate ent codetask go:test # Run database teststask go:lint # Check for issuestask go:run # Run with SQLite3task go:run:postgres # Run with PostgreSQLNext Steps
Section titled “Next Steps”- Learn how to use API Handlers to expose entities via HTTP
- Review existing repositories in
backend/internal/data/repo/for patterns