Skip to main content
Version: 0.7.0

Getting Started

This guide builds the smallest useful Quark application. It uses SQLite so you can run it locally without containers, but the same model and query code works with PostgreSQL, MySQL, MariaDB, SQL Server, and Oracle by changing the driver name passed to quark.New.

The Mental Model

Quark has three main pieces:

PieceRole
ClientOwns *sql.DB, dialect, limits, middleware, observers, cache, and schema helpers.
ModelA normal Go struct with db, pk, validation, and relation tags.
Query[T]An immutable typed builder created with quark.For[T](ctx, client).

Reads and writes start from quark.For[Model](ctx, client). Builder methods such as Where, OrderBy, Limit, Preload, and Cache return a cloned query, so you can reuse base queries safely.

Complete Example

package main

import (
"context"
"errors"
"log"
"time"

"github.com/jcsvwinston/quark"
_ "modernc.org/sqlite"
)

type User struct {
ID int64 `db:"id" pk:"true"`
Email string `db:"email" quark:"unique,not_null" validate:"required,email"`
Name string `db:"name" quark:"not_null"`
Active bool `db:"active" default:"1"`
Score int `db:"score" default:"0"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
DeletedAt *time.Time `db:"deleted_at"`
}

func (u *User) BeforeCreate(ctx context.Context) error {
now := time.Now()
u.CreatedAt = now
u.UpdatedAt = now
return nil
}

func (u *User) BeforeUpdate(ctx context.Context) error {
u.UpdatedAt = time.Now()
return nil
}

func main() {
ctx := context.Background()

client, err := quark.New("sqlite", "file:quark.db?cache=shared")
if err != nil {
log.Fatal(err)
}
defer client.Close()

if err := client.Migrate(ctx, &User{}); err != nil {
log.Fatal(err)
}

user := User{
Email: "alice@example.com",
Name: "Alice",
Active: true,
}

if err := quark.For[User](ctx, client).Create(&user); err != nil {
log.Fatal(err)
}

found, err := quark.For[User](ctx, client).Find(user.ID)
if err != nil {
log.Fatal(err)
}

found.Name = "Alice Walker"
if _, err := quark.For[User](ctx, client).Update(&found); err != nil {
log.Fatal(err)
}

active, err := quark.For[User](ctx, client).
Where("active", "=", true).
OrderBy("created_at", "DESC").
Limit(20).
List()
if err != nil {
log.Fatal(err)
}

log.Printf("active users: %d", len(active))

if _, err := quark.For[User](ctx, client).Delete(&found); err != nil {
log.Fatal(err)
}

_, err = quark.For[User](ctx, client).Find(found.ID)
if errors.Is(err, quark.ErrNotFound) {
log.Println("soft-deleted rows are hidden by default")
}
}

Create a Client

client, err := quark.New("sqlite", "file:quark.db?cache=shared")
if err != nil {
return err
}
defer client.Close()

The dialect is auto-detected from the driver name. Use WithDialect to override when the driver name is ambiguous (e.g. registering a custom dialect under pgx).

Define a Model

type User struct {
ID int64 `db:"id" pk:"true"`
Email string `db:"email" quark:"unique,not_null" validate:"required,email"`
Name string `db:"name" quark:"not_null"`
Active bool `db:"active" default:"1"`
DeletedAt *time.Time `db:"deleted_at"`
}

The db tag is the persisted column name. Fields without db are ignored by Migrate, inserts, updates, and scans. pk:"true" marks the primary key. If no pk:"true" tag exists, Quark falls back to a field tagged db:"id".

Table names are inferred by pluralizing and snake-casing the struct name: User maps to users, APIKey maps to api_keys. Implement TableName() when the database already uses another name.

func (User) TableName() string {
return "app_users"
}

Create Tables

if err := client.Migrate(ctx, &User{}); err != nil {
return err
}

Migrate creates missing tables and many-to-many join tables. It is intentionally simple and useful for development, tests, and initial schema creation. For live schema evolution, use Sync or versioned migrations as described in Migrations and Sync.

Insert Rows

user := User{
Email: "alice@example.com",
Name: "Alice",
Active: true,
}

if err := quark.For[User](ctx, client).Create(&user); err != nil {
return err
}

fmt.Println(user.ID)

Create validates the model, runs BeforeCreate, inserts the row, writes back the primary key when the dialect supports it, then runs AfterCreate.

Query Rows

Use Find for simple primary keys:

user, err := quark.For[User](ctx, client).Find(42)

Use Where, OrderBy, and Limit for lists:

users, err := quark.For[User](ctx, client).
Where("active", "=", true).
Where("score", ">=", 100).
OrderBy("created_at", "DESC").
Limit(50).
List()

List applies a safe default limit of 100 when you forget to call Limit. Use Iter or Cursor when you intentionally want to stream a large result set.

Update Rows

user.Name = "Alice Walker"
rows, err := quark.For[User](ctx, client).Update(&user)

Update is partial: zero values are skipped. This prevents a sparse struct from accidentally overwriting columns with false, 0, "", or nil.

When you do need to write a zero value, use UpdateMap with an explicit predicate:

rows, err = quark.For[User](ctx, client).
Where("id", "=", user.ID).
UpdateMap(map[string]any{
"active": false,
"score": 0,
})

UpdateMap requires a Where clause to avoid accidental full-table updates.

Delete Rows

If a model has a deleted_at column, Delete performs a soft delete:

rows, err := quark.For[User](ctx, client).Delete(&user)

Soft-deleted rows are hidden from normal Find, First, List, Count, and aggregate operations. Use Unscoped to include them:

allUsers, err := quark.For[User](ctx, client).Unscoped().List()

Use HardDelete to permanently remove a single entity:

rows, err = quark.For[User](ctx, client).HardDelete(&user)

Use DeleteBy when you want a hard delete by predicate without loading each row:

rows, err = quark.For[User](ctx, client).
Where("active", "=", false).
DeleteBy()

DeleteBy requires a Where clause. In the current API it performs a hard delete, even if the model has deleted_at.

Upsert

Use Upsert when an external feed or idempotent command should insert a row if it does not exist, or update selected columns if it conflicts.

incoming := User{
Email: "alice@example.com",
Name: "Alice",
Active: true,
}

err := quark.For[User](ctx, client).Upsert(
&incoming,
[]string{"email"},
[]string{"name", "active"},
)

The conflict columns must match a primary key or unique constraint in the database. Quark generates the appropriate ON CONFLICT, ON DUPLICATE KEY, or MERGE statement for the selected dialect.

Handle Common Errors

user, err := quark.For[User](ctx, client).Find(42)
switch {
case err == nil:
_ = user
case errors.Is(err, quark.ErrNotFound):
return nil
case errors.Is(err, quark.ErrConstraintViolation):
return fmt.Errorf("duplicate or invalid related row: %w", err)
case errors.Is(err, quark.ErrInvalidQuery):
return fmt.Errorf("query rejected before execution: %w", err)
default:
return err
}

ErrInvalidQuery usually means SQLGuard rejected a column, table, operator, raw query, or safety condition before the statement reached the driver.

Next Steps

NeedRead
Model tags, composite keys, validation, hooksModeling
Filters, scopes, pagination, streamingQuery Builder
Eager loading and association persistenceRelations
Bulk create, update, upsert, deleteBatch Operations
Schema evolutionMigrations and Sync