Grove

Schema & Models

Complete reference for Grove model definitions, field options, relations, the schema registry, and driver-specific behavior.

Grove models are plain Go structs annotated with struct tags. At registration time, Grove uses reflection to build a schema.Table that caches all metadata -- field names, types, constraints, relations, and indexes. After that initial pass, the hot path uses zero reflection.

See Dual Tag System for the full tag resolution rules (grove > bun > snake_case).

BaseModel

Embed grove.BaseModel to declare table-level options:

import "github.com/xraph/grove"

type User struct {
    grove.BaseModel `grove:"table:users,alias:u"`

    ID    int64  `grove:"id,pk,autoincrement"`
    Name  string `grove:"name,notnull"`
    Email string `grove:"email,notnull,unique"`
}
OptionDescriptionExample
table:nameTable or collection namegrove:"table:users"
alias:aTable alias for queriesgrove:"table:users,alias:u"

If no table: option is provided, the name defaults to the snake-cased, pluralized struct name (e.g., UserProfile becomes user_profiles).

Field Options

Flags

TagDescriptionExample
pkPrimary keygrove:"id,pk"
autoincrementAuto-incrementing columngrove:"id,pk,autoincrement"
notnullNOT NULL constraintgrove:"name,notnull"
nullzeroGo zero value treated as SQL NULLgrove:"deleted_at,nullzero"
uniqueUNIQUE constraintgrove:"email,unique"
soft_deleteSoft delete timestampgrove:"deleted_at,soft_delete,nullzero"
scanonlyRead-only; excluded from INSERT/UPDATEgrove:"computed,scanonly"
-Skip field entirelygrove:"-"

Key-Value Options

TagDescriptionExample
type:XOverride SQL/BSON typegrove:"data,type:jsonb"
default:XDEFAULT value expressiongrove:"status,default:'active'"
privacy:XPrivacy classification for hooksgrove:"ssn,privacy:pii"
driver:XRestrict to specific driver(s)grove:"pg_field,driver:pg"
index:XNamed indexgrove:"email,index:idx_email"
composite:XComposite index groupgrove:"first_name,composite:idx_name"
crdt:XCRDT type annotationgrove:"counter,crdt:counter"

Column Name Resolution

Column names follow a three-step fallback:

  1. Explicit name in tag -- grove:"my_col" uses my_col
  2. Tag present, no name -- grove:",notnull" uses ToSnakeCase(GoFieldName)
  3. No tag at all -- uses ToSnakeCase(GoFieldName)

The ToSnakeCase function handles acronyms correctly:

Go NameColumn Name
UserIDuser_id
HTMLParserhtml_parser
APIKeyURLapi_key_url
SimpleTestsimple_test

Soft Delete

Fields tagged with soft_delete change how deletion works:

  • DELETE becomes an UPDATE that sets the timestamp field
  • SELECT queries automatically filter WHERE deleted_at IS NULL
  • Call ForceDelete() on the query builder to bypass soft delete and issue a real DELETE
type Post struct {
    grove.BaseModel `grove:"table:posts"`

    ID        int64      `grove:"id,pk,autoincrement"`
    Title     string     `grove:"title,notnull"`
    DeletedAt *time.Time `grove:"deleted_at,soft_delete,nullzero"`
}

Use *time.Time or time.Time with nullzero so the zero value maps to NULL (meaning "not deleted").

ScanOnly Fields

Fields tagged scanonly are populated when scanning SELECT results but excluded from INSERT and UPDATE statements. Use this for computed columns, database-generated values, or aggregates:

type UserStats struct {
    grove.BaseModel `grove:"table:users"`

    ID        int64  `grove:"id,pk"`
    Name      string `grove:"name"`
    PostCount int    `grove:"post_count,scanonly"`
}

Privacy Classifications

The privacy: tag annotates fields with a classification that hooks can inspect. The classification string is stored in QueryContext.PrivacyColumns as a map[string]string mapping column name to classification.

type Customer struct {
    grove.BaseModel `grove:"table:customers"`

    ID    int64  `grove:"id,pk"`
    Email string `grove:"email,privacy:pii"`
    SSN   string `grove:"ssn,privacy:pii"`
    Notes string `grove:"notes,privacy:sensitive"`
}

See the PII Redaction Hook example for how to use this in practice.

Driver Hints

The driver: tag restricts a field to one or more specific drivers. When building queries for a driver that does not match the hint, the field is excluded. Comma-separate multiple drivers:

type Record struct {
    grove.BaseModel `grove:"table:records"`

    ID       int64  `grove:"id,pk"`
    Data     string `grove:"data,type:jsonb,driver:pg"`
    MongoMeta string `grove:"mongo_meta,type:object,driver:mongo"`
}

Relations

Declare relations using the rel: tag option with a join: mapping of base_col=join_col.

Has One

type User struct {
    grove.BaseModel `grove:"table:users"`

    ID      int64    `grove:"id,pk"`
    Profile *Profile `grove:"rel:has-one,join:id=user_id"`
}

Has Many

type User struct {
    grove.BaseModel `grove:"table:users"`

    ID    int64   `grove:"id,pk"`
    Posts []*Post `grove:"rel:has-many,join:id=author_id"`
}

Belongs To

type Post struct {
    grove.BaseModel `grove:"table:posts"`

    ID       int64 `grove:"id,pk"`
    AuthorID int64 `grove:"author_id"`
    Author   *User `grove:"rel:belongs-to,join:author_id=id"`
}

Many-to-Many

Many-to-many relations require a join_table: option:

type User struct {
    grove.BaseModel `grove:"table:users"`

    ID    int64   `grove:"id,pk"`
    Roles []*Role `grove:"rel:many-to-many,join_table:user_roles,join:id=user_id"`
}

Relation Type Aliases

The following spellings are accepted for the rel: value:

CanonicalAliases
has-onehasone
has-manyhasmany
belongs-tobelongsto
many-to-manymanytomany, m2m

Embedded Structs

Anonymous (embedded) struct fields are flattened into the parent table. This is useful for reusable field groups:

type Timestamps struct {
    CreatedAt time.Time `grove:"created_at,notnull,default:current_timestamp"`
    UpdatedAt time.Time `grove:"updated_at,nullzero"`
}

type User struct {
    grove.BaseModel `grove:"table:users"`

    ID   int64  `grove:"id,pk,autoincrement"`
    Name string `grove:"name,notnull"`
    Timestamps // fields are flattened into the users table
}

Unexported embedded fields are skipped.

Composite Indexes

Multiple fields sharing the same composite: group name are combined into a single composite index:

type User struct {
    grove.BaseModel `grove:"table:users"`

    ID        int64  `grove:"id,pk"`
    FirstName string `grove:"first_name,composite:idx_full_name"`
    LastName  string `grove:"last_name,composite:idx_full_name"`
}

Schema Registry

Grove caches schema.Table metadata per model type in a thread-safe registry. Users interact with it through db.RegisterModel():

db.RegisterModel((*User)(nil))
db.RegisterModel((*Post)(nil))

For advanced introspection, the internal schema.Registry exposes:

MethodDescription
Register(model) (*Table, error)Registers and caches; returns cached on subsequent calls
Get(model) *TableReturns nil if not registered
MustGet(model) *TablePanics if not registered

MongoDB: id to _id Mapping

When using the MongoDB driver, a primary key field with column name id is automatically mapped to MongoDB's _id field in both queries and $jsonSchema validation. This applies to both bson.ObjectID and other PK types:

type User struct {
    ID   mongodriver.ObjectID `grove:"id,pk"`    // stored as _id in MongoDB
    Name string               `grove:"name,notnull"`
}

See the MongoDB driver page for full driver-specific documentation.

CRDT Types

The crdt: tag annotates fields for CRDT (Conflict-free Replicated Data Type) conflict resolution. Supported values include lww (last-writer-wins), counter, and set:

type Document struct {
    grove.BaseModel `grove:"table:documents"`

    ID      string `grove:"id,pk"`
    Title   string `grove:"title,crdt:lww"`
    Views   int64  `grove:"views,crdt:counter"`
}

See the CRDT documentation for full details on conflict resolution and sync.

On this page