Skip to content

BoBch27/firevault_go

Repository files navigation

🔥 Firevault

A production-ready Firestore ODM for Go with built-in validation, type-safe queries, and effortless data modelling.

Go Report Card Go Reference Go Version License: MIT


Why Firevault?

Working with Firestore's Go SDK can be verbose and error-prone. Firevault provides a clean, type-safe abstraction layer that makes Firestore feel natural in Go, without sacrificing flexibility or performance.

// Before: Verbose Firestore SDK
doc, err := client.Collection("users").Doc(id).Get(ctx)
if err != nil { /* handle */ }
var user User
doc.DataTo(&user)
// Manual validation...
// Manual transformation...
client.Collection("users").Doc(id).Set(ctx, map[string]interface{}{
    "email": strings.ToLower(user.Email),
    "age": user.Age,
    // ...
})

// After: Clean Firevault API
collection := firevault.Collection[User](connection, "users")
user, err := collection.FindOne(ctx, NewQuery().ID(id))
// Validation + transformation handled automatically ✨

✨ Key Features

  • 🎯 Type-Safe Queries - Leverage Go generics for compile-time type safety
  • ✅ Built-in Validation - Extensive validation rules (required, email, min/max, custom validators)
  • 🔄 Automatic Transformations - Transform data before storage (lowercase, trim, hash passwords, etc.)
  • 🔐 Transaction Support - First-class transaction support with clean API
  • 📦 Single Struct Design - Use the same model for Create, Read, Update operations
  • 🚀 Performance - Validation caching for zero-overhead repeated operations
  • 🛠 Extensible - Register custom validators and transformers easily
  • 📖 Rich Error Messages - Detailed validation errors with custom formatting support

📦 Installation

go get github.com/bobch27/firevault_go

Requirements: Go 1.24+ (uses generics)


🚀 Quick Start

1. Connect to Firestore

import (
    "context"
    "log"
    "github.com/bobch27/firevault_go"
)

// Sets your Google Cloud Platform project ID.
projectID := "YOUR_PROJECT_ID"
ctx := context.Background()

connection, err := firevault.Connect(ctx, projectID)
if err != nil {
	log.Fatalln("firevault initialisation failed:", err)
}
defer connection.Close()

💡 Pro Tip: A Firevault Connection is thread-safe and designed to be used as a singleton. It caches validation metadata for optimal performance.

2. Define Your Model

type User struct {
    Name     string   `firevault:"name,required,omitempty"`
    Email    string   `firevault:"email,required,email,is_unique,omitempty"`
    Password string   `firevault:"password,required,min=6,transform:hash_pass,omitempty"`
    Address  *Address `firevault:"address,omitempty"`
    Age      int      `firevault:"age,required,min=18,omitempty"`
}

type Address struct {
    Line1 string `firevault:",omitempty"`
    City  string `firevault:"-"` // Ignored field
}

3. CRUD Operations

collection := firevault.Collection[User](connection, "users")

// Create
user := User{
    Name:     "Bobby Donev",
    Email:    "hello@bobbydonev.com",
    Password: "secure123",
    Age:      26,
}
id, err := collection.Create(ctx, &user)

// Read
user, err := collection.FindOne(ctx, NewQuery().ID(id))
users, err := collection.Find(ctx, NewQuery().Where("age", ">=", 26))

// Update
updates := User{Password: "newpassword"}
err = collection.Update(ctx, NewQuery().ID(id), &updates)

// Delete
err = collection.Delete(ctx, NewQuery().ID(id))

📚 Table of Contents


🏗 Defining Models

Models are Go structs with firevault tags that define field behaviour, validation, and transformation rules.

Tag Syntax

`firevault:"fieldName,rule1,rule2=param,transform:rule3,omitempty"`

Rules are executed in order (except omitempty variants, which can be placed anywhere).

Custom Struct Tag (Optional)

By default, Firevault uses the firevault struct tag. You can customise this:

// Use "db" instead of "firevault" as the struct tag
connection, err := firevault.Connect(ctx, projectID, "db")

// Now in your structs:
type User struct {
    Name string `db:"name,required,omitempty"`  // ✅
    // Instead of:
    // Name string `firevault:"name,required,omitempty"`
}

Built-in Rules

Rule Description
omitempty Omits field if set to default value (e.g., 0 for int, "" for string)
omitempty_create Like omitempty, but only for Create method
omitempty_update Like omitempty, but only for Update method
omitempty_validate Like omitempty, but only for Validate method
dive Recursively validate nested structs in slices/maps
- Ignores the field completely

Validation Rules

Rule Description Example
required Field must not be default value required
required_create Required only on Create required_create
required_update Required only on Update required_update
required_validate Required only on Validate required_validate
min Minimum value/length min=18
max Maximum value/length max=100
email Valid email address email

Custom Validators

Register your own validation logic:

connection.RegisterValidation(
    "is_upper",
    ValidationFunc(func(fs FieldScope) (bool, error) {
        if fs.Kind() != reflect.String {
            return false, nil
        }
        s := fs.Value().String()
        return s == strings.ToUpper(s), nil
    }),
)

// Use it in your model
type User struct {
    Name string `firevault:"name,required,is_upper,omitempty"`
}

Context-aware validators for database checks:

connection.RegisterValidation(
    "is_unique",
    ValidationFuncCtxTx(func(ctx context.Context, tx *Transaction, fs FieldScope) (bool, error) {
        doc, err := Collection[User](connection, fs.Collection()).FindOne(
            ctx,
            NewQuery().Where(fs.Field(), "==", fs.Value().Interface()),
            NewOptions().Transaction(tx),
        )
        if err != nil {
            return false, err
        }
        return doc.ID == "", nil
    }),
)

Transformations

Transform field values before storage with the transform: prefix:

Built-in transformations:

  • uppercase - Convert to uppercase
  • lowercase - Convert to lowercase
  • trim_space - Remove leading/trailing whitespace

Custom transformations:

connection.RegisterTransformation(
    "hash_pass",
    TransformationFunc(func(fs FieldScope) (interface{}, error) {
        password := fs.Value().String()
        hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
        return string(hashed), err
    }),
)

type User struct {
    // Password gets hashed before storage
    Email    string `firevault:"email,required,transform:lowercase,email,omitempty"`
    Password string `firevault:"password,required,min=6,transform:hash_pass,omitempty"`
}

Order Matters: Place transformations before validation rules if you want to validate the transformed value.


💾 CRUD Operations

Create

Adds a new document to Firestore after validation.

collection := firevault.Collection[User](connection, "users")

user := User{
    Name:  "Bobby Donev",
    Email: "hello@bobbydonev.com",
    Age:   26,
}

// Auto-generated ID
id, err := collection.Create(ctx, &user)

// Custom ID
id, err := collection.Create(
    ctx,
    &user,
    NewOptions().CustomID("custom-id"),
)

// Allow zero values for specific fields
user := User{Age: 0} // Normally omitted with omitempty
id, err := collection.Create(
    ctx,
    &user,
    NewOptions().AllowEmptyFields("age"),
)

Update

Updates documents matching a query.

updates := User{Password: "newpassword123"}

// Update by ID
err := collection.Update(
    ctx,
    NewQuery().ID("user-id"),
    &updates,
)

// Update multiple documents
err := collection.Update(
    ctx,
    NewQuery().Where("age", "<", 18),
    &updates,
)

// Update boolean to false (explicitly allow zero value)
updates := User{IsActive: false}
err := collection.Update(
    ctx,
    NewQuery().ID("user-id"),
    &updates,
    NewOptions().AllowEmptyFields("is_active"),
)

// Replace entire document
err := collection.Update(
    ctx,
    NewQuery().ID("user-id"),
    &user,
    NewOptions().ReplaceAll(),
)

// Replace specific nested field
updates := User{Address: &Address{Line1: "New Street"}}
err := collection.Update(
    ctx,
    NewQuery().ID("user-id"),
    &updates,
    NewOptions().ReplaceFields("address.Line1"),
)

⚠️ Important: Without omitempty or omitempty_update, non-specified fields will be set to Go's default values. Use these rules to prevent unintended updates.

Validate

Validates data without touching the database.

user := User{Email: "HELLO@EXAMPLE.COM"}

err := collection.Validate(ctx, &user)
// Email is now "hello@example.com" if transform:lowercase is used

// Validate as if creating
err := collection.Validate(ctx, &user, NewOptions().AsCreate())

// Validate as if updating
err := collection.Validate(ctx, &user, NewOptions().AsUpdate())

Delete

Deletes documents matching a query.

// Delete by ID
err := collection.Delete(ctx, NewQuery().ID("user-id"))

// Delete with precondition
err := collection.Delete(
    ctx,
    NewQuery().ID("user-id"),
    NewOptions().RequireExists(),
)

// Delete if last updated at specific time
err := collection.Delete(
    ctx,
    NewQuery().ID("user-id"),
    NewOptions().RequireLastUpdateTime(timestamp),
)

Find

Retrieves multiple documents.

users, err := collection.Find(
    ctx,
    NewQuery().
        Where("age", ">=", 18).
        OrderBy("name", Asc).
        Limit(10),
)

// Access results
for _, doc := range users {
    fmt.Println(doc.ID)        // Document ID
    fmt.Println(doc.Data.Name) // User data
    fmt.Println(doc.Metadata.CreateTime) // Metadata
}

FindOne

Retrieves a single document.

user, err := collection.FindOne(
    ctx,
    NewQuery().ID("user-id"),
)

if user.ID == "" {
    // Document not found
}

Count

Counts documents matching a query.

count, err := collection.Count(
    ctx,
    NewQuery().Where("age", ">=", 18),
)
fmt.Printf("Found %d adults\n", count)

🔍 Querying

Build complex queries with a fluent API:

query := NewQuery().
    Where("status", "==", "active").
    Where("age", ">=", 18).
    OrderBy("createdAt", Desc).
    Limit(20).
    Offset(10)

users, err := collection.Find(ctx, query)

Available Query Methods

Method Description Example
ID(ids...) Filter by document IDs ID("id1", "id2")
Where(path, op, value) Filter by field Where("age", ">=", 18)
OrderBy(path, direction) Sort results OrderBy("name", Asc)
Limit(n) Limit results Limit(10)
LimitToLast(n) Last N results LimitToLast(5)
Offset(n) Skip N results Offset(20)
StartAt(values...) Cursor start (inclusive) StartAt(25)
StartAfter(values...) Cursor start (exclusive) StartAfter(25)
EndBefore(values...) Cursor end (exclusive) EndBefore(50)
EndAt(values...) Cursor end (inclusive) EndAt(50)

Operators

==, !=, <, <=, >, >=, array-contains, array-contains-any, in, not-in

Directions

Asc, Desc


⚙️ Options

Options provide fine-grained control over operations:

options := NewOptions().
    AllowEmptyFields("is_active", "score").
    SkipValidation("password").
    ModifyOriginal().
    Transaction(tx)

Available Options

Option Applies To Description
AllowEmptyFields(paths...) Create, Update, Validate Ignore omitempty for specified fields
SkipValidation(paths...) Create, Update, Validate Skip validation for specified fields
ModifyOriginal() Create, Update, Validate Update original struct with transformed values
CustomID(id) Create Use custom document ID
ReplaceAll() Update Replace entire document
ReplaceFields(paths...) Update Replace specific fields completely
RequireExists() Delete Only delete if document exists
RequireLastUpdateTime(t) Update, Delete Optimistic locking with timestamp
Transaction(tx) All Execute within transaction
AsCreate() Validate Validate as if creating
AsUpdate() Validate Validate as if updating

🔐 Transactions

Firevault provides first-class transaction support for atomic operations:

err := connection.RunTransaction(ctx, func(ctx context.Context, tx *Transaction) error {
    opts := NewOptions().Transaction(tx)
    
    // Read documents
    docs, err := collection.Find(ctx, NewQuery().Where("age", "==", 18), opts)
    if err != nil {
        return err
    }
    
    // Build update query from doc IDs
    updateQuery := NewQuery()
    for _, doc := range docs {
        updateQuery = updateQuery.ID(doc.ID)
    }
    
    // Update within transaction
    updates := &User{Age: 19}
    return collection.Update(ctx, updateQuery, updates, opts)
})

⚠️ Transaction Limits:

  • Only ID clause is considered for Update/Delete in transactions
  • Use Find first to get IDs, then pass them to Update/Delete
  • Maximum ~500 operations per transaction (Firestore limit)

🚨 Error Handling

Firevault provides detailed validation errors through the FieldError interface:

Custom Error Formatters

connection.RegisterErrorFormatter(func(fe FieldError) error {
    switch fe.Rule() {
    case "min":
        return fmt.Errorf("%s must be at least %s characters", 
            fe.DisplayField(), fe.Param())
    case "email":
        return fmt.Errorf("%s must be a valid email address", 
            fe.DisplayField())
    default:
        return nil // Let default error through
    }
})

Manual Error Handling

id, err := collection.Create(ctx, &user)
if err != nil {
    var fErr FieldError
    if errors.As(err, &fErr) {
        fmt.Printf("Field: %s\n", fErr.Field())          // "email"
        fmt.Printf("Rule: %s\n", fErr.Rule())            // "email"
        fmt.Printf("Value: %v\n", fErr.Value())          // "invalid@"
        fmt.Printf("Param: %s\n", fErr.Param())          // ""
        fmt.Printf("Message: %s\n", fErr.Error())        // Default message
    } else {
        // Non-validation error (network, permissions, etc.)
        fmt.Println(err)
    }
}

⚡ Performance

Firevault is designed for production use with performance in mind:

  • Validation Caching: Struct validation metadata is parsed once and cached
  • Zero Allocation Queries: Query builders reuse allocations where possible
  • Efficient Reflection: Validation uses cached type information
  • Benchmarked: Comparable to industry-leading validators like go-playground/validator (both with and without caching)

See BENCHMARKS.md for detailed performance comparisons.

Best Practices

  1. Use singleton Connection: One connection per application
  2. Reuse CollectionRef instances: Lightweight, but avoid unnecessary creation
  3. Batch operations: Use queries for bulk updates instead of loops
  4. Index your queries: Follow Firestore indexing guidelines

📖 Examples

Complete User Management System

package main

import (
    "context"
    "fmt"
    "log"
    "github.com/bobch27/firevault_go"
)

type User struct {
    Name     string    `firevault:"name,required,transform:trim_space,omitempty"`
    Email    string    `firevault:"email,required,email,transform:lowercase,omitempty"`
    Password string    `firevault:"password,required,min=8,transform:hash_pass,omitempty"`
    Age      int       `firevault:"age,required,min=13,omitempty"`
    IsActive bool      `firevault:"is_active,omitempty"`
}

func main() {
    ctx := context.Background()
    
    connection, err := firevault.Connect(ctx, "your-project-id")
    if err != nil {
        log.Fatal(err)
    }
    defer connection.Close()
    
    collection := firevault.Collection[User](connection, "users")
    
    // Create user
    user := User{
        Name:     "  Bobby Donev  ", // Will be trimmed
        Email:    "HELLO@EXAMPLE.COM", // Will be lowercased
        Password: "secure123", // Will be hashed
        Age:      26,
        IsActive: true,
    }
    
    id, err := collection.Create(ctx, &user)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Printf("Created user: %s\n", id)
    
    // Find active users
    activeUsers, err := collection.Find(
        ctx,
        NewQuery().
            Where("is_active", "==", true).
            Where("age", ">=", 18).
            OrderBy("name", Asc),
    )
    
    for _, doc := range activeUsers {
        fmt.Printf("User: %s (%s)\n", doc.Data.Name, doc.Data.Email)
    }
    
    // Deactivate user
    err = collection.Update(
        ctx,
        NewQuery().ID(id),
        &User{IsActive: false},
        NewOptions().AllowEmptyFields("is_active"),
    )
}

More Examples

Check out the /examples directory (coming soon) for:

  • Transaction examples
  • Custom validators and transformers
  • Batch operations
  • Advanced querying patterns
  • Error handling strategies

🤝 Contributing

Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change.


📄 License

MIT License - see LICENSE for details.


🙏 Acknowledgments

Built with ❤️ for the Go and Firebase communities.

Special thanks to the Firestore team for the excellent SDK, and to Dean Karn for the incredible go-playground/validator package, which inspired Firevault's validation system, particularly:

  • The FieldScope pattern for passing field context to validation functions
  • The FieldError interface for detailed validation error reporting
  • The validation function signatures and registration conventions

Firevault expands on these ideas with a fully custom validation engine and a complete Firestore ODM layer - including field transformations, method-specific validations, transaction support, and CRUD operations tailored for Firestore.


⭐ If you find Firevault useful, please consider giving it a star on GitHub! ⭐

Made with 🔥 by Bobby Donev

About

A Firestore ODM, with built-in validation, to make life easier for Go devs.

Topics

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Contributors 2

  •  
  •  

Languages