r3

package module
v0.0.0-...-883a801 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 26, 2026 License: MIT Imports: 16 Imported by: 0

README

R3

Everything is a repo. Everything is an R3.

A universal, backend-agnostic CRUD repository abstraction for Go.

Go Reference CI Go Report Card Go Version


R3 (pronounced "ree" /riː/, as in repo) provides a single generic CRUD[T, ID] interface that works identically across PostgreSQL, MySQL, SQLite, MongoDB, JSON/YAML/TOML files, and any other data source. Your business code talks to r3.CRUD - it never knows or cares what's behind it.

// Same interface, same query, different backends
userRepo    r3.CRUD[User, int64]      // PostgreSQL via GORM
productRepo r3.CRUD[Product, string]  // MongoDB
configRepo  r3.CRUD[Config, string]   // YAML files on disk

[!NOTE] R3 is in early development (pre-1.0). The core API is stable in spirit, but details may change before a tagged release. Questions, ideas, and feedback are very welcome - see Feedback.

Contents

Why R3

R3 is not about swapping backends. Most systems pick a database and stick with it.

R3 is about the fact that real systems use multiple data sources: a relational DB for core data, MongoDB for event logs, config files for feature flags, an external REST API for third-party data. Without a shared interface, each one gets its own query patterns, its own error handling, its own permission logic.

With R3, all of them speak the same language. More importantly, features compose across all of them: wrap any repo with permissions, audit history, metrics, or validation - regardless of what storage is behind it.

Install

go get github.com/amberpixels/r3

Then pull in the driver(s) you need, e.g. github.com/amberpixels/r3/drivers/gorm.

Quick Start

import (
    "github.com/amberpixels/r3"
    r3gorm "github.com/amberpixels/r3/drivers/gorm"
)

// Define your model (standard GORM model)
type City struct {
    ID   int64  `gorm:"primaryKey"`
    Name string
}

// Create a repository
cityRepo := r3gorm.NewGormCRUD[City, int64](db)

// Create
city, err := cityRepo.Create(ctx, City{Name: "Berlin"})

// Get by ID — missing records return r3.ErrNotFound on every backend
city, err := cityRepo.Get(ctx, 42)
if errors.Is(err, r3.ErrNotFound) {
    // respond 404, etc.
}

// List with filters, sorting, and pagination.
// Short-form helpers (r3.Eq, r3.Gt, ...) keep simple filters terse.
cities, total, err := cityRepo.List(ctx, r3.Query{
    Filters: r3.Filters{
        r3.Eq("name", "Berlin"),
    },
    Sorts: r3.Sorts{
        r3.NewSortAscSpec(r3.NewFieldSpec("name")),
    },
    Pagination: r3.NewPaginationSpec(1, 25),
})

// Count matching records without materializing rows
n, err := cityRepo.Count(ctx, r3.Query{Filters: r3.Filters{r3.Eq("name", "Berlin")}})

// Update
city.Name = "Munich"
city, err = cityRepo.Update(ctx, city)

// Patch (partial update - only specified fields)
city, err = cityRepo.Patch(ctx, city, r3.Fields{r3.NewFieldSpec("name")})

// Delete
err = cityRepo.Delete(ctx, 42)

Architecture

R3 is organized in five layers. Each layer has a clear responsibility and depends only on the layers above it.

r3 (core)           Interfaces + query model. Zero dependencies.
  |
  +-- dialects/     Pure converters: r3 types <-> format-specific representations.
  |                 No I/O, no state. Two categories:
  |                   Data-store:    sql, bson
  |                   Serialization: json, yaml, toml, url
  |
  +-- engine/       Complete CRUD implementations per storage category.
  |                 The heavy lifting lives here.
  |                   sql   - database/sql + reflection + Flavor
  |                   mongo - MongoDB driver + reflection
  |                   file  - filesystem + codecs + in-memory query eval
  |
  +-- drivers/      Ready-to-use constructors for specific libraries.
  |                   pq, pgx, mysql, sqlite3 - wrap engine/sql
  |                   gorm, bun, gopg         - ORM-native, share query prep
  |                   mongo                   - wraps engine/mongo
  |
  +-- features/     Composable decorators that wrap ANY r3.CRUD[T, ID].
                      permissions, history, metrics, validation,
                      softdelete, transactor

Core (r3 package)

The interfaces and query model. This is the contract everything else implements.

Interfaces:

  • CRUD[T, ID] - Full read+write repository (composes Querier + Commander)
  • Querier[T, ID] - Read-only: Get, List
  • Commander[T, ID] - Write-only: Create, Update, Patch, Delete
  • Transactor[T, ID] - Opt-in transaction support: BeginTx

Query model - a single composable Query struct:

  • Filters - Field-operator-value conditions with recursive AND/OR groups
  • Sorts - Multi-column sort with direction and NULLS FIRST/LAST
  • PaginationSpec - Offset-based (page number + page size)
  • CursorSpec - Keyset/cursor-based (forward/backward with opaque tokens)
  • Fields - Column selection (SELECT specific fields)
  • Preloads - Eager loading of related entities

Queries are immutable values. MergeWith() combines queries from different sources (e.g. defaults + user request + permission scope) without mutation.

Dialects

Stateless, bidirectional converters between r3 types and format-specific representations.

Data-store dialects convert r3 queries into storage-native primitives:

  • dialects/sql - FilterSpec -> WHERE status = ? AND age > ? with parameterized args
  • dialects/bson - FilterSpec -> bson.D{{Key: "status", Value: "active"}}

Serialization dialects convert r3 queries to/from interchange formats:

  • dialects/json - REST API request/response bodies
  • dialects/yaml - Configuration files
  • dialects/toml - Configuration files
  • dialects/url - URL query parameters (?sort=name:asc&page=2&status=active)

Dialects are pure functions. They have no I/O, no database connections, no state. Engines and drivers consume them; most application code doesn't import them directly.

Engines

Complete r3.CRUD implementations for a category of storage backend. Each engine handles reflection, query building, and execution for its storage type.

  • engine/sql - Generic SQL via database/sql. Uses Flavor to handle differences between Postgres ($1 placeholders, RETURNING), MySQL (? placeholders, LAST_INSERT_ID), and SQLite. Provides BaseCRUD[T, ID] that raw SQL drivers embed, and PreparedListQuery that ORM drivers share for filter/sort/pagination translation.

  • engine/mongo - MongoDB via the official Go driver v2. Handles BSON document building, projection, cursor pagination, relation preloading via separate queries.

  • engine/file - Filesystem-based storage with pluggable codecs (JSON, YAML). Applies filters, sorts, and pagination in-memory. Supports single-file (one JSON per collection) and directory (one file per entity) modes.

Drivers

Ready-to-use constructors that wire up an engine for a specific client library.

Raw SQL drivers (embed engine/sql.BaseCRUD):

Driver Package Library Notes
PostgreSQL drivers/pq lib/pq $1 placeholders, RETURNING
PostgreSQL drivers/pgx jackc/pgx $1 placeholders, RETURNING
MySQL drivers/mysql go-sql-driver/mysql ? placeholders, no RETURNING
SQLite drivers/sqlite3 mattn/go-sqlite3 ? placeholders, RETURNING (3.35+)

ORM drivers (use ORM API natively, share PreparedListQuery for query translation):

Driver Package Library Preloads Soft-delete
GORM drivers/gorm gorm.io/gorm Preload() Unscoped()
Bun drivers/bun uptrace/bun Relation() WhereAllWithDeleted()
go-pg drivers/gopg go-pg/pg/v10 Relation() AllWithDeleted()

NoSQL drivers:

Driver Package Library
MongoDB drivers/mongo mongo-driver/v2

All drivers expose a Raw() escape hatch for queries that go beyond the r3 interface.

Features (Decorators)

Composable middleware that wraps any r3.CRUD[T, ID], regardless of backend. This is where R3's "everything is a repo" philosophy pays off - the same permission logic works for your Postgres entities and your MongoDB logs.

// Stack features via decoration:
repo := permissions.WithPermissions(
    history.WithHistory(
        validation.WithValidation(
            r3gorm.NewGormCRUD[Order, int64](db),
            orderValidator,
        ),
        historyStore, history.WithIDFunc[Order, int64](func(o Order) int64 { return o.ID }),
    ),
    orderPermissions,
)
// repo is still r3.CRUD[Order, int64] - fully transparent

Available features:

  • permissions - Policy-based authorization. Gates every CRUD operation through a user-defined Checker. Supports entity-aware row-level checks and scope injection (automatic filter injection into List queries). Bring your own auth logic.

  • history - Change tracking / audit log. Records every mutation as a ChangeRecord with field-level diffs. Supports snapshots, revert-to-version, and tree queries. The history store is itself an r3.CRUD[ChangeRecord, string].

  • metrics - Domain-level analytics. 10 built-in collectors (action counts, latency, popularity, error rates, etc.). Configurable time bucketing, aggregation, and retention. The metrics store is itself an r3.CRUD[MetricRecord, string].

  • validation - Pre-mutation validation. Bring your own validator (go-playground/validator, ozzo-validation, plain Go). Patch-aware and state-transition-aware (can compare new vs existing entity).

  • softdelete - Adds Restore() and HardDelete() to any CRUD that supports soft-delete.

  • transactor - Surfaces transaction capabilities (BeginTx, InTx) from the underlying driver.

Filters

Build filters with the short-form helpers (a plain field name) for the common case, or drop down to the FieldSpec-based forms when you need table hints or nested paths:

// Short-form helpers — terse, take a plain field name
r3.Eq("status", "active")
r3.Gt("age", 18)
r3.In("country", []string{"DE", "FR"})
r3.Like("name", "%john%")
r3.ILike("name", "%john%")
r3.Between("price", 10, 100)        // inclusive

// FieldSpec forms — for table hints / nested paths
r3.F(r3.NewFieldSpec("status"), "active")
r3.Fop(r3.NewFieldSpec("age"), r3.OperatorGte, 18)

// Logical groups (compose either form)
r3.And(
    r3.Eq("status", "active"),
    r3.Gte("age", 18),
)

r3.Or(
    r3.Eq("role", "admin"),
    r3.Eq("role", "moderator"),
)

// NULL checks (nil value + Eq/Ne operator)
r3.Eq("deleted_at", nil)  // IS NULL

Available operators: Eq, Ne, Gt, Gte, Lt, Lte, In, NotIn, Like, NotLike, ILike, Between, BetweenEx, BetweenExInc, BetweenIncEx, Exists.

Schema & Capabilities

r3.SchemaOf[T]() reflects an entity's struct tags into a Schema — an ordered set of capability-bearing Attributes. Each attribute declares what it may do via five capabilities: Filterable, Sortable, Queryable (select & output), Creatable, and Mutable. Defaults are permissive — a plain scalar column gets all five — and tags only ever tighten them:

type Campaign struct {
    ID        int64     `r3:"id,pk"`                          // read-only identity
    Title     string    `r3:"title"`                          // all capabilities
    Status    string    `r3:"status,enum:draft|active|paused"` // enum + allowed values
    Slug      string    `r3:"slug,immutable"`                 // creatable once, then read-only
    Spend     int       `r3:"spend,readonly"`                 // feed-synced; users can't write
    Secret    string    `r3:"secret,no-filter,no-sort,no-output"` // hidden everywhere
    CreatedAt time.Time `r3:"created_at"`                     // server-managed (read-only)
}

The SQL engines consume the schema automatically:

  • Reads are validated. An unknown or disallowed filter/sort/select field becomes a typed error before any SQL runsErrUnknownField, ErrFieldNotFilterable, ErrFieldNotSortable, ErrFieldNotQueryable — instead of a backend 500. Each error wraps the offending field name.
  • Writes are shaped. Create writes only Creatable columns; Update/Patch write only Mutable columns. A full Update can no longer clobber created_at or resurrect a soft-deleted row. The created_at/updated_at timestamps are system-managed: the engine stamps them with server time (read-only to callers, written by the system), so created_at is set on create and updated_at bumps on every write.

Capabilities are the public ceiling: the permissions feature only narrows them per-actor/row, never widens. For an audited system/worker write of a user-immutable column (e.g. a nightly feed sync), open the explicit door — it skips only the capability check, never the structural floor (the PK and computed attributes stay unwritable), and the write still passes through history/metrics:

r3.SystemWriter(repo).Update(ctx, feedRow)          // ergonomic wrapper
repo.Update(r3.WithoutWriteGuard(ctx), feedRow)     // or the raw context marker

A schema serializes to a stable, public-only JSON shape via dialects/schema.MarshalSchema (non-queryable attributes are omitted), so a consumer can describe an entity to a frontend for column pickers and a dynamic filter UI.

Pagination

List paginates by default — with no Pagination set it caps results at r3.PageSizeDefault (100), so a forgotten pagination never accidentally scans a whole table. There are three ways to get more:

// 1. A custom page / size, per query
r3.Query{Pagination: r3.NewPaginationSpec(1, 250)}  // page 1, 250 per page

// 2. Everything, for this one query (clears the default cap)
all, total, err := repo.List(ctx, r3.Query{Pagination: r3.Unpaginated()})

// 3. Everything by default, for this repo (global opt-out)
repo := r3gorm.NewGormCRUD[City, int64](db,
    r3.WithConfig(r3.Config{Defaults: r3.DefaultsConfig{Unpaginated: true}}),
)
// repo.List(ctx) now returns all rows; individual queries can still paginate.

Cursor-based pagination is the alternative to offset (requires at least one sort):

r3.Query{
    Cursor: r3.NewCursorAfter(nextToken, 25),
    Sorts:  r3.Sorts{r3.NewSortDescSpec(r3.NewFieldSpec("created_at"))},
}

You can also detect truncation from the returned total without changing anything:

items, total, err := repo.List(ctx)
if total > int64(len(items)) {
    // there are more rows than were returned
}

Transactions

err := r3.InTx(ctx, repo, func(tx r3.CRUD[Order, int64]) error {
    order, err := tx.Create(ctx, newOrder)
    if err != nil {
        return err // auto-rollback
    }
    // ... more operations within the same transaction
    return nil // auto-commit
})

URL Query Parsing

Parse HTTP request parameters directly into r3 queries:

import r3url "github.com/amberpixels/r3/dialects/url"

// GET /api/cities?fields=id,name&sort=name:asc&page=2&page_size=25&status=active
q, err := r3url.ParseQuery(r.URL.Query(),
    r3url.WithDjangoStyleFilters("status", "name"),
)
cities, total, err := cityRepo.List(ctx, q)

Requirements

  • Go 1.26+

AI disclosure

R3's code is written with heavy AI assistance - and that's by design. But the AI is a tool here, not the author of record:

  • Every architectural decision is made by a human. The layering, the interfaces, the trade-offs - those are deliberate human choices, not whatever a model happened to produce.
  • Every line of code is read and reviewed by a human before it's pushed. Nothing lands in this repository unread.
  • The code is written AI-first. It's deliberately optimized to be easy for AI to read, grep, update, and extend - not primarily for human ergonomics. Clear, greppable names and consistent structure win over cleverness.

Responsibility for the code is human. 🤖🤝🧑

Feedback

R3 isn't accepting pull requests at this stage - but questions, ideas, bug reports, and feedback are genuinely welcome. Please open an issue, and see CONTRIBUTING.md for the why and the details. It's MIT-licensed, so you're also free to fork and adapt it for your own work. For security issues, see SECURITY.md.

License

MIT © amberpixels

Documentation

Overview

Package r3 provides a universal CRUD repository abstraction for Go.

The core interface is CRUD, a generic interface parameterized by entity type T and primary key type ID. It composes Querier (Get, List, Count) and Commander (Create, Update, Patch, Delete) — use the narrower sub-interfaces when full CRUD access is not needed (e.g. read-only config stores only need Querier).

Queries are built with composable value types — Filters, Sorts, PaginationSpec, CursorSpec, Fields, and Preloads — combined into a single Query struct. Queries are immutable; Query.MergeWith returns a new value, making it safe to combine queries from different sources (defaults, user request, permission scope). Build filters with the short-form helpers (Eq, Gt, In, Like, Between, ...) for the common case, or the FieldSpec-based forms (F, Fop) when you need table hints or nested paths.

Schema and capabilities

SchemaOf reflects an entity's struct tags into a Schema — an ordered set of capability-bearing [Attribute]s. Each attribute declares what it may do via five capabilities (Filterable, Sortable, Queryable, Creatable, Mutable); defaults are permissive (a plain scalar gets all five) and tags only tighten them (no-filter, no-sort, no-output, readonly, immutable, enum). The PK, the created_at/updated_at timestamps, and the soft-delete column are read-only by default.

Schema.ValidateQuery turns an unknown or disallowed filter/sort/select field into a typed error (ErrUnknownField, ErrFieldNotFilterable, ErrFieldNotSortable, ErrFieldNotQueryable) before any SQL is built, and the SQL engine shapes writes to honor Creatable/Mutable — a full Update can no longer clobber created_at or resurrect a soft-deleted row. The created_at and updated_at columns are system-managed: read-only to callers, but stamped with server time by the engine (created_at on create, updated_at on every write). Capabilities are the public ceiling; the permissions feature only narrows them per-actor.

An audited system/worker door writes a user-immutable column explicitly: WithoutWriteGuard on the context, or the SystemWriter wrapper. It skips only the capability check — the structural floor (computed attributes and the PK are never writable) holds, and history/metrics still record the write.

Errors and pagination

Get normalizes every backend's "not found" condition to the ErrNotFound sentinel, so callers detect a missing record with errors.Is the same way regardless of driver. List paginates by default (PageSizeDefault items); pass Unpaginated to opt out, or compare the returned total against the slice length to detect truncation. Count answers "how many match?" without materializing rows — only Filters and IncludeTrashed affect its result.

Project layout

The project is organized in five layers, each depending only on the layers above:

  • r3 (this package): Interfaces and query model. Zero external dependencies.
  • dialects/: Stateless converters between r3 types and format-specific representations. Two categories: data-store (sql, bson) and serialization (json, yaml, toml, url, schema). No I/O.
  • engine/: Complete CRUD implementations per storage category (sql, mongo, file). Contains reflection, query building, and execution logic.
  • drivers/: Ready-to-use constructors for specific libraries. Raw SQL drivers (pq, pgx, mysql, sqlite3) embed engine/sql.BaseCRUD. ORM drivers (gorm, bun, gopg) use their own ORM API but share engine/sql.PreparedListQuery for filter/sort/pagination translation. The mongo driver wraps engine/mongo.
  • features/: Composable decorators (permissions, history, metrics, validation, softdelete, transactor) that wrap any r3.CRUD regardless of backend.

Key design principle

Features compose across backends. The same permission checker, audit log, or metrics collector works whether the underlying repo is PostgreSQL, MongoDB, or a YAML file. This makes r3 particularly useful in systems that use multiple data sources — the behavioral layer is written once and applied everywhere.

Index

Constants

View Source
const (
	// PageSizeDefault is a GLOBAL per-package default page size if no pagination was specified.
	PageSizeDefault = 100
)

Variables

View Source
var (
	// ErrInvalidCursor is returned when a cursor token cannot be decoded.
	ErrInvalidCursor = errors.New("invalid cursor token")

	// ErrCursorRequiresSort is returned when cursor pagination is used without any sort columns.
	ErrCursorRequiresSort = errors.New("cursor pagination requires at least one sort column")
)

Sentinel errors for cursor pagination.

View Source
var (
	// ErrUnknownField is returned when a referenced field is not declared by the schema.
	ErrUnknownField = errors.New("unknown field")
	// ErrFieldNotFilterable is returned when a non-filterable field appears in Query.Filters.
	ErrFieldNotFilterable = errors.New("field is not filterable")
	// ErrFieldNotSortable is returned when a non-sortable field appears in Query.Sorts.
	ErrFieldNotSortable = errors.New("field is not sortable")
	// ErrFieldNotQueryable is returned when a non-queryable field appears in Query.Fields.
	ErrFieldNotQueryable = errors.New("field is not queryable")
)

Schema validation errors. Schema.ValidateQuery wraps the offending field name (fmt.Errorf("%w: %q", err, name)) so a consumer can surface a useful 400-class message instead of leaking a backend driver error (which would otherwise be a 500).

View Source
var ErrInvalidIdentifier = errors.New("invalid identifier")

ErrInvalidIdentifier is returned when a field name contains characters that are not valid SQL identifiers.

View Source
var ErrInvalidPatchField = errors.New("invalid patch field")

ErrInvalidPatchField is returned by a write (Patch, or full Update SET-shaping) when a field name does not match any attribute in the schema, or names an attribute that is not mutable (e.g. PK, created_at, soft-delete, immutable).

View Source
var ErrNoPatchFields = errors.New("patch requires at least one field")

ErrNoPatchFields is returned by Patch when the Fields list is empty or nil.

View Source
var ErrNotFound = errors.New("r3: record not found")

ErrNotFound is returned by Get (and other single-record operations) when no record matches the requested ID.

Every backend normalizes its native "no rows" / "no documents" error to this sentinel — database/sql's sql.ErrNoRows, GORM's gorm.ErrRecordNotFound, MongoDB's mongo.ErrNoDocuments, and the file engine's internal not-found error all surface as r3.ErrNotFound. This lets business code detect a missing record identically regardless of the concrete driver:

user, err := repo.Get(ctx, id)
if errors.Is(err, r3.ErrNotFound) {
    // respond 404, etc.
}
View Source
var ErrTransactionsNotSupported = errors.New("r3: transactions not supported by this repository")

ErrTransactionsNotSupported is returned by InTx when the repository does not implement the Transactor interface.

View Source
var SystemActor = Actor{ID: "", Type: "system"}

SystemActor is the default actor used when no actor is set in the context.

Functions

func As

func As[C any, T any, ID comparable](repo CRUD[T, ID]) (C, bool)

As finds the first layer in the decorator chain (starting at repo and following Unwrapper.Unwrap) that implements C, enabling capability detection through decorators. For example, to reach a backend's soft-delete support regardless of how many decorators wrap it:

sd, ok := r3.As[SoftDeleter[ID]](repo)

It returns the zero value of C and false if no layer implements C.

func EncodeCursor

func EncodeCursor(cv CursorValues) (string, error)

EncodeCursor serializes cursor values into an opaque base64 token.

func ExtractBetweenBounds

func ExtractBetweenBounds(value any) (any, any, error)

ExtractBetweenBounds extracts low and high values from a between filter value. The value must be a slice or array with exactly 2 elements.

func FieldsToStrings

func FieldsToStrings(fields Fields) []string

FieldsToStrings converts Fields to a []string of field names. This is a backend-agnostic helper reused by sqlbase, mongobase, etc.

func FinalizeCount

func FinalizeCount[T any](entities []T, paginatedCount int64, isPaginated bool) ([]T, int64)

FinalizeCount returns (entities, totalCount) with the correct total. If pagination was not active, totalCount is simply len(entities). This is a backend-agnostic helper reused by sqlbase, mongobase, etc.

func FinalizeCountCursor

func FinalizeCountCursor[T any](entities []T) ([]T, int64)

FinalizeCountCursor returns (entities, -1) for cursor-paginated results, since total count is not available with keyset pagination.

func InTx

func InTx[T any, ID comparable](
	ctx context.Context,
	repo CRUD[T, ID],
	fn func(tx CRUD[T, ID]) error,
) error

InTx runs fn inside a transaction if the repository supports it. It begins a transaction, calls fn with a transactional CRUD, and:

  • commits if fn returns nil
  • rolls back if fn returns an error or panics

The repository may be wrapped in decorators (validation, history, permissions, ...). InTx walks the decorator chain to the backend Transactor, begins the transaction there, and re-applies the same decorator stack on top of the transaction-bound CRUD. This means the CRUD passed to fn is fully decorated, so decorated behaviour still runs for writes inside the transaction rather than being bypassed.

Returns ErrTransactionsNotSupported if no layer in the chain implements Transactor.

Example:

err := r3.InTx(ctx, userRepo, func(tx r3.CRUD[User, int64]) error {
    user, err := tx.Create(ctx, newUser)
    if err != nil {
        return err
    }
    return nil
})

func SupportsTx

func SupportsTx[T any, ID comparable](repo CRUD[T, ID]) bool

SupportsTx reports whether repo's decorator chain reaches a backend that implements Transactor. Unlike a direct type assertion it sees through decorators, and unlike As it does not treat an intermediate decorator that merely exposes BeginTx as proof of support — only the backend counts.

func ValidateIdentifier

func ValidateIdentifier(s string) error

ValidateIdentifier checks that s is a safe SQL identifier or dotted identifier path. Each dot-separated segment must match [a-zA-Z_][a-zA-Z0-9_]*. Examples of valid identifiers: "id", "user_name", "user.profile", "orders.items.product_name". Examples of invalid identifiers: "", "1col", "a b", "x;y", "col--", "table.*".

func WithActor

func WithActor(ctx context.Context, actor Actor) context.Context

WithActor returns a new context with the given Actor attached. Typically called in HTTP middleware after authentication:

ctx := r3.WithActor(r.Context(), r3.Actor{ID: "42", Type: "user"})

Authorization data can ride along on Claims, where policies read it back (e.g. via the permissions feature's AccessRequest.Actor):

ctx := r3.WithActor(r.Context(), r3.Actor{ID: "42", Type: "user", Claims: principal})

func WithoutWriteGuard

func WithoutWriteGuard(ctx context.Context) context.Context

WithoutWriteGuard returns a context that skips the engine's write-capability enforcement for writes made with it. Use it for audited system/worker writes of a user-immutable column; prefer the SystemWriter wrapper for ergonomics.

It does not bypass the structural floor (computed/PK remain unwritable), and it does not bypass any decorator (history/metrics still record the write).

func WriteGuardBypassed

func WriteGuardBypassed(ctx context.Context) bool

WriteGuardBypassed reports whether the context carries the write-guard bypass. Engines consult it before enforcing Creatable/Mutable.

Types

type Actor

type Actor struct {
	// ID identifies who performed the action (e.g. user ID, API key ID, service name).
	ID string

	// Type categorizes the actor (e.g. "user", "service", "system", "cron").
	Type string

	// Claims carries optional application-defined authorization data attached to
	// the actor (roles, group/tenant memberships, scopes, capabilities, ...).
	// R3 itself never inspects Claims — it only propagates it on the context so
	// policies can read it back. The permissions feature passes it through to
	// Checker/Scoper via AccessRequest.Actor, letting an authorization policy ride
	// entirely on the canonical actor instead of a separate, parallel context key.
	//
	// It is intentionally untyped (any): the application owns the shape and
	// type-asserts it (commonly to a pointer of its own principal type). A nil
	// Claims is the norm for system/service actors and the attribution-only
	// features (metrics, history), which ignore it.
	Claims any
}

Actor represents the identity performing a CRUD operation. It is stored in context.Context and automatically picked up by features like metrics and history for attribution.

A zero-value Actor represents the system/anonymous actor.

func GetActor

func GetActor(ctx context.Context) Actor

GetActor retrieves the Actor from the context. Returns SystemActor if no actor was set.

type Attribute

type Attribute struct {
	// Name is the public/wire name, e.g. "created_at" (snake_case). Filters,
	// sorts, and selected fields reference attributes by this name; the engine
	// translates it to a physical column/path.
	Name string

	// Type is the logical data type, used to pick default operators and drive
	// frontend filter widgets.
	Type DataType

	// Caps is the bitset of capabilities (Filterable, Sortable, ...).
	Caps Capability

	// Ops are the filter operators allowed for this attribute. Derivation fills
	// it from the Type's defaults when the attribute is filterable; nil means
	// "the defaults for Type" (or none, when not filterable).
	Ops []FilterOperatorSpec

	// Enum holds the allowed values when Type == TypeEnum.
	Enum []string

	// Relation describes the target when Type == TypeRel.
	Relation *RelationRef

	// Computed marks an attribute with no backing column (reserved; computed
	// execution is out of scope — see the schema design doc, §8). A computed
	// attribute can never be written: the structural floor has nowhere to put a
	// value, so no escape hatch can corrupt it.
	Computed bool
}

Attribute is one declared, capability-bearing member of an entity.

func (Attribute) Has

func (a Attribute) Has(c Capability) bool

Has reports whether the attribute carries every capability in c.

type CRUD

type CRUD[T any, ID comparable] interface {
	Querier[T, ID]
	Commander[T, ID]
}

CRUD is the full read+write repository interface. It composes Querier (Get, List) and Commander (Create, Update, Patch, Delete).

func SystemWriter

func SystemWriter[T any, ID comparable](repo CRUD[T, ID]) CRUD[T, ID]

SystemWriter wraps the top of a repository (decorator) chain so its write methods inject WithoutWriteGuard into the context before delegating. The full chain still runs — history/metrics/soft-delete all see the write — but the engine skips the Creatable/Mutable check. Reads and the structural floor are unaffected.

r3.SystemWriter(repo).Update(ctx, feedRow) // writes a user-immutable column, audited

type Capability

type Capability uint8

Capability is a bitset of what an Attribute is allowed to do. The five capabilities are the public contract — the ceiling of what any API caller may do. The permissions feature can only narrow this per-actor/row, never widen it (see the schema design doc, §2.3).

const (
	// Filterable means the attribute may appear in Query.Filters.
	Filterable Capability = 1 << iota
	// Sortable means the attribute may appear in Query.Sorts.
	Sortable
	// Queryable means the attribute may appear in Query.Fields (SELECT) and in serialized output.
	Queryable
	// Creatable means the attribute may be set by Create.
	Creatable
	// Mutable means the attribute may be changed after creation — gates both Update and Patch.
	Mutable
)

type Commander

type Commander[T any, ID comparable] interface {
	// Create inserts a new record into the database.
	Create(context.Context, T) (T, error)

	// Update modifies an existing record in the database with optional parameters.
	Update(context.Context, T) (T, error)

	// Patch performs a partial update, modifying only the columns specified by Fields.
	// The entity must have its primary key set. Only the fields named in the Fields
	// list are written to the database; all other columns remain unchanged.
	// Returns the full entity after the update.
	Patch(context.Context, T, Fields) (T, error)

	// Delete removes a record by its ID.
	// It can use soft delete (if it's turned on the repository level)
	Delete(context.Context, ID) error
}

Commander is the write-only subset of repository operations. It provides methods for creating, modifying, and deleting entities.

type Config

type Config struct {
	// Naming controls how R3 maps well-known fields to storage column names.
	Naming NamingConfig

	// Defaults controls default query behavior (e.g. page size).
	Defaults DefaultsConfig
}

Config holds framework-level configuration for R3 repositories. It controls naming conventions, default query behavior, and other cross-cutting concerns that are not specific to any single model.

Use DefaultConfig to get a Config with sensible defaults, then override individual fields as needed.

Config is intended to be read-only after construction. Pass it to engine/driver constructors via WithConfig.

func DefaultConfig

func DefaultConfig() Config

DefaultConfig returns a Config with all defaults applied.

type CursorDirection

type CursorDirection int8

CursorDirection indicates whether cursor pagination goes forward or backward.

const (
	// CursorForward pages forward from the "after" cursor position.
	CursorForward CursorDirection = iota
	// CursorBackward pages backward from the "before" cursor position.
	CursorBackward
)

func (CursorDirection) String

func (d CursorDirection) String() string

String returns "forward" or "backward".

type CursorSpec

type CursorSpec struct {
	// After is the opaque cursor token for forward pagination.
	After string
	// Before is the opaque cursor token for backward pagination.
	Before string
	// Limit is the maximum number of results to return. 0 means use default.
	Limit int
}

CursorSpec specifies cursor/keyset-based pagination. At most one of After or Before should be set. If both are set, After takes precedence. Limit controls the maximum number of items returned per page.

func NewCursorAfter

func NewCursorAfter(after string, limit int) *CursorSpec

NewCursorAfter creates a CursorSpec for forward pagination after the given token.

func NewCursorBefore

func NewCursorBefore(before string, limit int) *CursorSpec

NewCursorBefore creates a CursorSpec for backward pagination before the given token.

func NewCursorFirst

func NewCursorFirst(limit int) *CursorSpec

NewCursorFirst creates a CursorSpec for the first page (no cursor, just limit).

func (*CursorSpec) Clone

func (c *CursorSpec) Clone() *CursorSpec

Clone creates a deep copy of the CursorSpec.

func (*CursorSpec) Direction

func (c *CursorSpec) Direction() CursorDirection

Direction returns CursorForward or CursorBackward based on which token is set.

func (*CursorSpec) GetLimit

func (c *CursorSpec) GetLimit() int

GetLimit returns the limit, defaulting to PageSizeDefault if not set.

func (*CursorSpec) MergeWith

func (c *CursorSpec) MergeWith(other *CursorSpec) *CursorSpec

MergeWith merges this cursor spec with another, with other taking precedence.

func (*CursorSpec) String

func (c *CursorSpec) String() string

String returns a debug-friendly string representation.

func (*CursorSpec) Token

func (c *CursorSpec) Token() string

Token returns the active cursor token (After takes precedence over Before).

type CursorValues

type CursorValues map[string]any

CursorValues holds the sort-column values that define the cursor position. Keys are column names; values are the column values at that position.

func DecodeCursor

func DecodeCursor(token string) (CursorValues, error)

DecodeCursor deserializes an opaque base64 token back into CursorValues. Returns empty CursorValues (not nil) for an empty token.

type DataType

type DataType string

DataType is the logical type of an attribute. It is engine-agnostic — it drives the default filter operators and (later) a frontend's filter widgets, not any storage representation.

const (
	TypeInt    DataType = "int"
	TypeFloat  DataType = "float"
	TypeString DataType = "string"
	TypeBool   DataType = "bool"
	TypeTime   DataType = "time"
	TypeEnum   DataType = "enum"
	TypeJSON   DataType = "json"
	TypeRel    DataType = "relation"
)

type Defaults

type Defaults struct {
	ListQuery Query
	GetQuery  Query
}

Defaults stores the default query values for List and Get operations. It is shared by all drivers (database/sql-based, GORM, Bun, go-pg, MongoDB, etc.).

func NewDefaults

func NewDefaults() Defaults

NewDefaults returns Defaults initialized with reasonable default queries.

type DefaultsConfig

type DefaultsConfig struct {
	// PageSize is the default number of items per page when pagination
	// is active but no explicit page size is provided.
	// Default: 100 (same as PageSizeDefault)
	PageSize int

	// Unpaginated, when true, makes List return ALL matching rows by default —
	// no implicit page-size cap. Individual queries can still opt back into
	// pagination per call by setting Query.Pagination. Takes precedence over
	// PageSize.
	//
	// Use with care on large tables; prefer the per-query r3.Unpaginated()
	// escape hatch when only some call sites need everything.
	Unpaginated bool
}

DefaultsConfig controls default query behavior.

type DefaultsManager

type DefaultsManager struct {
	// contains filtered or unexported fields
}

DefaultsManager provides thread-safe access to Defaults. Embed this in your CRUD struct to get SetDefaultListQuery, SetDefaultGetQuery, GetDefaultListQuery, and GetDefaultGetQuery for free.

func NewDefaultsManager

func NewDefaultsManager() DefaultsManager

NewDefaultsManager creates a DefaultsManager with reasonable defaults.

func NewDefaultsManagerWithConfig

func NewDefaultsManagerWithConfig(cfg Config) DefaultsManager

NewDefaultsManagerWithConfig creates a DefaultsManager that respects the given Config.

If Config.Defaults.Unpaginated is set, the default list query is unbounded (List returns all rows unless a query opts into pagination). Otherwise, if Config.Defaults.PageSize differs from the global default, the default list query is initialized with that page size.

func (*DefaultsManager) GetDefaultGetQuery

func (dm *DefaultsManager) GetDefaultGetQuery() Query

GetDefaultGetQuery returns the default GetQuery (thread-safe).

func (*DefaultsManager) GetDefaultListQuery

func (dm *DefaultsManager) GetDefaultListQuery() Query

GetDefaultListQuery returns the default ListQuery (thread-safe).

func (*DefaultsManager) MergeGetQuery

func (dm *DefaultsManager) MergeGetQuery(qarg ...Query) Query

MergeGetQuery merges the given query args with the default get query.

func (*DefaultsManager) MergeListQuery

func (dm *DefaultsManager) MergeListQuery(qarg ...Query) Query

MergeListQuery merges the given query args with the default list query.

func (*DefaultsManager) SetDefaultGetQuery

func (dm *DefaultsManager) SetDefaultGetQuery(q Query)

SetDefaultGetQuery sets the default GetQuery (thread-safe).

func (*DefaultsManager) SetDefaultListQuery

func (dm *DefaultsManager) SetDefaultListQuery(q Query)

SetDefaultListQuery sets the default ListQuery (thread-safe).

type FieldSpec

type FieldSpec string

FieldSpec is the simplest possible implementation of a field. FieldSpec is just a string - it can be the name of the field in database, etc.

func NewFieldSpec

func NewFieldSpec(s string) *FieldSpec

func (*FieldSpec) Clone

func (f *FieldSpec) Clone() *FieldSpec

Clone returns a clone of the field.

func (*FieldSpec) String

func (f *FieldSpec) String() string

String simply returns its value.

type Fields

type Fields []*FieldSpec

Fields is a slice of *FieldSpec.

func (Fields) Clone

func (fs Fields) Clone() Fields

Clone returns a safe full-clone of the fields list.

func (*Fields) Dedupe

func (fs *Fields) Dedupe()

Dedupe removes duplicates from the fields list.

func (Fields) MergeWith

func (fs Fields) MergeWith(other Fields) Fields

MergeWith merges (combines) fields with other fields.

type FilterOperatorSpec

type FilterOperatorSpec int8

FilterOperatorSpec represents an operator to be used in a filter. For now, not all r3 operators are supported by every possible dialect. But the idea is to provide here in r3 the most possibly full list of operators that we need. TODO: might be refactored into a more complex struct

const (
	OperatorUnspecified  FilterOperatorSpec = iota
	OperatorEq                              // =
	OperatorNe                              // !=
	OperatorExists                          // exists
	OperatorGt                              // >
	OperatorGte                             // >=
	OperatorLt                              // <
	OperatorLte                             // <=
	OperatorBetween                         // between_inc meaning []
	OperatorBetweenEx                       // between_exc meaning ()
	OperatorBetweenExInc                    // between_exc_inc meaning (]
	OperatorBetweenIncEx                    // between_inc_exc meaning [)
	OperatorIn                              // in
	OperatorNotIn                           // not in
	OperatorLike                            // like
	OperatorNotLike                         // not like
	OperatorILike                           // ilike (like + case insensitive)
)

func (*FilterOperatorSpec) String

func (op *FilterOperatorSpec) String() string

String is implemented for debugging purposes, so the FilterOperatorSpec is a fmt.Stringer. Note: Protect with the `exhausted` linter.

type FilterSpec

type FilterSpec struct {
	Field    *FieldSpec
	Operator FilterOperatorSpec
	Value    any

	// AND Children should be declared inside AND
	And Filters
	// OR Children should be declared inside OR
	Or Filters

	// Relation, when non-empty, makes this a relationship ("has") filter: it
	// matches rows whose declared relation `Relation` (by the same struct field
	// name used for preloads) has at least one related row satisfying all of
	// RelationFilter. Field/Operator/Value/And/Or are ignored when Relation is
	// set.
	//
	// Relationship filters are resolved by the driver into a key-set In filter
	// before SQL translation (see the GORM driver's relation lowering), so they
	// work on any backend regardless of native subquery support. A dialect may
	// later compile them natively (EXISTS) as an optimization.
	// omitempty so a non-relationship filter serializes exactly as before (the
	// relationship fields are absent unless used).
	Relation       string  `json:",omitempty"`
	RelationFilter Filters `json:",omitempty"`
}

FilterSpec represents a filtering criteria with a field, an operator, and a value.

func And

func And(filters ...*FilterSpec) *FilterSpec

And is a shortcut for NewFilterSpecAndGroup.

func Between

func Between(field string, lo, hi any) *FilterSpec

Between builds an inclusive `field BETWEEN lo AND hi` filter.

func Eq

func Eq(field string, value any) *FilterSpec

Eq builds a `field = value` filter.

func Exists

func Exists(field string, value any) *FilterSpec

Exists builds a `field exists` filter (presence check). The value is the expected existence as a bool where the backend supports it.

func F

func F(field *FieldSpec, value any) *FilterSpec

F is a shorthand for NewFilterSpec(field, OperatorEq, value).

func FILike

func FILike(field *FieldSpec, value any) *FilterSpec

func FLike

func FLike(field *FieldSpec, value any) *FilterSpec

func Fop

func Fop(field *FieldSpec, operator FilterOperatorSpec, value any) *FilterSpec

Fop is a shorthand for NewFilterSpec() Fop is "F" for filter and "op" for operator.

func Gt

func Gt(field string, value any) *FilterSpec

Gt builds a `field > value` filter.

func Gte

func Gte(field string, value any) *FilterSpec

Gte builds a `field >= value` filter.

func Has

func Has(relation string, inner ...*FilterSpec) *FilterSpec

Has builds a relationship ("has") filter: it matches rows whose declared relation `relation` (by struct field name — the same name used for preloads) has at least one related row satisfying all of `inner`. Example:

r3.Has("Squads", r3.In("id", []int64{1, 3}))  // rows linked to squad 1 or 3

The inner filters are evaluated against the related entity. Drivers resolve the relation to a key set and rewrite this to an In filter, so it works on every backend regardless of native subquery support.

Resolution happens in the driver, so a Has filter does not round-trip through the serialization dialects (json/url/yaml/toml) — build it in Go. When used as a permission scope, enforcing it on Get requires permissions.WithIDFunc.

func ILike

func ILike(field string, value any) *FilterSpec

ILike builds a case-insensitive `field ILIKE value` filter.

func In

func In(field string, value any) *FilterSpec

In builds a `field IN (values...)` filter. The value is typically a slice.

func Like

func Like(field string, value any) *FilterSpec

Like builds a case-sensitive `field LIKE value` filter.

func Lt

func Lt(field string, value any) *FilterSpec

Lt builds a `field < value` filter.

func Lte

func Lte(field string, value any) *FilterSpec

Lte builds a `field <= value` filter.

func Ne

func Ne(field string, value any) *FilterSpec

Ne builds a `field != value` filter.

func NewFilterSpec

func NewFilterSpec(field *FieldSpec, operator FilterOperatorSpec, value any) *FilterSpec

NewFilterSpec constructs a FilterSpec (not an AND/OR group).

func NewFilterSpecAndGroup

func NewFilterSpecAndGroup(filters ...*FilterSpec) *FilterSpec

func NewFilterSpecOrGroup

func NewFilterSpecOrGroup(filters ...*FilterSpec) *FilterSpec

func NotIn

func NotIn(field string, value any) *FilterSpec

NotIn builds a `field NOT IN (values...)` filter. The value is typically a slice.

func NotLike

func NotLike(field string, value any) *FilterSpec

NotLike builds a case-sensitive `field NOT LIKE value` filter.

func Or

func Or(filters ...*FilterSpec) *FilterSpec

Or is a shortcut for NewFilterSpecOrGroup.

func (*FilterSpec) Clone

func (f *FilterSpec) Clone() *FilterSpec

Clone returns a deep clone of the filter.

func (*FilterSpec) String

func (f *FilterSpec) String() string

String returns just a string representation of the filter (as JSON). As all fields are exported, we're OK with this.

type Filters

type Filters []*FilterSpec

Filters represents a list of *FilterSpec.

func (Filters) Clone

func (fs Filters) Clone() Filters

Clone returns a safe full-clone of the filters list.

func (Filters) MergeWith

func (fs Filters) MergeWith(other Filters) Filters

MergeWith merges (combines) filters with other filters.

type JSONColumn

type JSONColumn[T any] struct {
	Val T
}

JSONColumn is a generic wrapper that stores a value of type T as a JSON string in SQL databases. It implements sql.Scanner, driver.Valuer, json.Marshaler, and json.Unmarshaler so it works transparently with both SQL drivers and JSON APIs.

Use it for struct fields that should be persisted as a JSON blob in a single column (e.g. []FieldChange, Metadata maps, nested config objects).

Zero dependencies beyond database/sql/driver and encoding/json (both stdlib).

func NewJSONColumn

func NewJSONColumn[T any](v T) JSONColumn[T]

NewJSONColumn creates a JSONColumn wrapping the given value.

func (JSONColumn[T]) MarshalJSON

func (j JSONColumn[T]) MarshalJSON() ([]byte, error)

MarshalJSON implements json.Marshaler — transparent JSON serialization of the wrapped value.

func (*JSONColumn[T]) Scan

func (j *JSONColumn[T]) Scan(src any) error

Scan implements sql.Scanner — reads a JSON string (or []byte) from SQL and unmarshals into T.

func (*JSONColumn[T]) UnmarshalJSON

func (j *JSONColumn[T]) UnmarshalJSON(data []byte) error

UnmarshalJSON implements json.Unmarshaler — transparent JSON deserialization into the wrapped value.

func (JSONColumn[T]) Value

func (j JSONColumn[T]) Value() (driver.Value, error)

Value implements driver.Valuer — marshals T to a JSON string for SQL storage.

type NamingConfig

type NamingConfig struct {
	// CreatedAtField is the storage column name for creation timestamps.
	// Default: "created_at"
	CreatedAtField string

	// UpdatedAtField is the storage column name for update timestamps.
	// Default: "updated_at"
	UpdatedAtField string

	// DeletedAtField is the storage column name for soft-delete timestamps.
	// Default: "deleted_at"
	DeletedAtField string
}

NamingConfig controls how R3 maps well-known fields to storage column names. Empty strings mean "use the default".

type Option

type Option func(*Options)

Option is a functional option for configuring R3 repositories. Pass options to engine/driver constructors to customize behavior.

func WithConfig

func WithConfig(cfg Config) Option

WithConfig sets the R3 framework-level configuration.

type Options

type Options struct {
	// Config is the framework-level configuration.
	Config Config
}

Options holds the resolved configuration for a repository. Engine and driver constructors call ResolveOptions to apply functional options and get the final values.

func DefaultOptions

func DefaultOptions() Options

DefaultOptions returns Options initialized with sensible defaults.

func ResolveOptions

func ResolveOptions(opts ...Option) Options

ResolveOptions applies functional options to the default Options and returns the result.

type PaginationSpec

type PaginationSpec struct {
	// PageNum is 1-indexed page number (1 = first page).
	PageNum maybe.Int
	// PageSize is the number of items per page.
	PageSize maybe.Int
}

PaginationSpec is the core pagination type using PageNum/PageSize.

func DefaultPagination

func DefaultPagination() *PaginationSpec

DefaultPagination returns a PaginationSpec with default page size.

func NewPaginationSpec

func NewPaginationSpec(pageNum int, pageSize ...int) *PaginationSpec

NewPaginationSpec creates a new PaginationSpec with given page number and page size.

func NewPaginationSpecWithSize

func NewPaginationSpecWithSize(pageSize int) *PaginationSpec

NewPaginationSpecWithSize creates a new PaginationSpec with only page size specified.

func NoPagination

func NoPagination() *PaginationSpec

NoPagination returns a PaginationSpec with no pagination limits.

func Unpaginated

func Unpaginated() *PaginationSpec

Unpaginated returns a PaginationSpec that disables the default page-size cap, so List returns every matching record.

By default List paginates (PageSizeDefault items per page); a caller that expects "give me everything" would otherwise silently get a truncated slice. Make the intent explicit:

all, total, err := repo.List(ctx, r3.Query{Pagination: r3.Unpaginated()})

Passing this on a query overrides any configured default page size (it clears the inherited cap during query merge). To make a whole repo unpaginated by default instead, set Config.Defaults.Unpaginated.

Unpaginated is an intention-revealing alias for NoPagination.

func (*PaginationSpec) Clone

func (p *PaginationSpec) Clone() *PaginationSpec

Clone creates a deep copy of the PaginationSpec.

func (*PaginationSpec) GetPageNum

func (p *PaginationSpec) GetPageNum() int

GetPageNum returns the page number (1-indexed), defaulting to 1 if not set.

func (*PaginationSpec) GetPageSize

func (p *PaginationSpec) GetPageSize() int

GetPageSize returns the page size, defaulting to PageSizeDefault if not set.

func (*PaginationSpec) IsPaginated

func (p *PaginationSpec) IsPaginated() bool

IsPaginated returns true if pagination is configured.

func (*PaginationSpec) MergeWith

func (p *PaginationSpec) MergeWith(other *PaginationSpec) *PaginationSpec

MergeWith merges this pagination with another, with other taking precedence.

func (*PaginationSpec) String

func (p *PaginationSpec) String() string

String returns string representation of pagination.

func (*PaginationSpec) ToLimitOffset

func (p *PaginationSpec) ToLimitOffset() (int, int)

ToLimitOffset converts PageNum/PageSize to Limit/Offset for database queries.

type PreloadSpec

type PreloadSpec struct {
	Name string
}

PreloadSpec means a simple possible preload (name of a table/collection).

func NewPreloadSpec

func NewPreloadSpec(name string) *PreloadSpec

NewPreloadSpec creates a PreloadSpec.

func (*PreloadSpec) Clone

func (t *PreloadSpec) Clone() *PreloadSpec

Clone returns a newly created struct of PreloadSpec.

func (*PreloadSpec) GetName

func (t *PreloadSpec) GetName() string

GetName returns the name of the current preload.

func (*PreloadSpec) String

func (t *PreloadSpec) String() string

String simply returns name of the preload.

type Preloads

type Preloads []*PreloadSpec

Preloads is a slice of *PreloadSpec.

func (Preloads) Clone

func (preloads Preloads) Clone() Preloads

Clone returns a cloned list of given preloads.

func (*Preloads) Dedupe

func (preloads *Preloads) Dedupe()

Dedupe removes duplicates from the preloads list.

func (Preloads) MergeWith

func (preloads Preloads) MergeWith(other Preloads) Preloads

MergeWith merges (combines) preloads with other preloads.

type Querier

type Querier[T any, ID comparable] interface {
	// Get retrieves a record by its ID with optional parameters.
	Get(context.Context, ID, ...Query) (T, error)

	// List retrieves records based on the provided query parameters.
	List(context.Context, ...Query) ([]T, int64, error)

	// Count returns the number of records matching the query's filters.
	//
	// Only Filters and IncludeTrashed affect the result — pagination, sorts,
	// fields, and preloads are ignored. Called with no query it counts every
	// (non-trashed) record. It is the efficient way to answer "how many?"
	// without materializing rows.
	Count(context.Context, ...Query) (int64, error)
}

Querier is the read-only subset of repository operations. It provides methods for retrieving entities without modifying them.

Use Querier when you need read-only access to a repository, for example when reading configuration or building reports.

type Query

type Query struct {
	Pagination *PaginationSpec

	// Cursor enables keyset/cursor-based pagination as an alternative to offset-based.
	// When set, Cursor takes precedence over Pagination. The two are mutually exclusive.
	Cursor *CursorSpec

	Fields   Fields   // Specific fields to retrieve.
	Filters  Filters  // []*FilterSpec
	Sorts    Sorts    // []*SortSpec
	Preloads Preloads // []*PreloadSpec

	// IncludeTrashed when true will still return trashed (soft-deleted) records.
	IncludeTrashed maybe.Bool
}

Query wraps everything from r3: Pagination, Fields, Filters, Sorts, Preloads.

func DefaultQuery

func DefaultQuery() Query

DefaultQuery returns the default Query (with reasonable params).

func NewQuery

func NewQuery() Query

NewQuery returns an empty Query.

func (Query) Clone

func (q Query) Clone() Query

Clone clones the query.

func (Query) MergeWith

func (q Query) MergeWith(other Query) Query

MergeWith merges this query with another, returning a new Query (no mutation).

Fields, Filters, and Preloads accumulate (the union of both). Sorts and Pagination OVERRIDE: when other specifies them, they replace the inherited values rather than stacking under them. This makes other the higher-precedence layer — typically a per-call query merged over a repo's defaults.

type RelationRef

type RelationRef struct {
	// Target is the related entity's logical name (snake_case of its type).
	Target string
	// Kind is the relationship kind ("has-many", "belongs-to", "many-to-many").
	Kind string
	// Label is the attribute on the target used as a human-facing label
	// (optional; populated via With for introspection).
	Label string
}

RelationRef points an Attribute (Type == TypeRel) at its target entity.

type Rewrapper

type Rewrapper[T any, ID comparable] interface {
	Rewrap(inner CRUD[T, ID]) CRUD[T, ID]
}

Rewrapper is implemented by decorators that can rebuild themselves around a different inner CRUD. It lets InTx/BeginTxChain re-apply the decorator stack on top of a transaction-bound CRUD, so decorated behaviour (validation, history, permissions, ...) still runs inside the transaction instead of being bypassed.

Rewrap must return a decorator equivalent to the receiver but delegating to inner. Stateful decorators should share their backing state (stores, locks) with the rebuilt instance.

type Schema

type Schema struct {
	// contains filtered or unexported fields
}

Schema is the logical, engine-agnostic descriptor of an entity: an ordered set of capability-bearing Attributes plus lookup helpers. It lives in the core r3 package next to Query/Filters and contains no storage details (no SQL column names, no BSON paths) — each engine owns the physical binding, keyed by attribute name.

A Schema is immutable: derive one with SchemaOf and layer overrides with With, both of which return a fresh Schema. The zero Schema has no attributes; ValidateQuery treats it as "no schema" and validates nothing, which keeps the engine seam back-compatible for callers that pass no schema.

Vocabulary note: an Attribute is a declared member of an entity, whereas a Field/FieldSpec is a reference to one inside a Query. The Schema is the set of valid Fields, plus what each is allowed to do.

func SchemaOf

func SchemaOf[T any](opts ...SchemaOption) Schema

SchemaOf reflects T's struct tags into a logical Schema with default capabilities (see the schema design doc, §2.7): a plain scalar column is queryable, filterable, sortable, creatable, and mutable; the PK, the created_at/updated_at timestamps, and the soft-delete column are read-only; relations are queryable (preload) only. Tags only ever tighten these defaults.

The default (no-option) result is cached per type T.

func (Schema) Attributes

func (s Schema) Attributes() []Attribute

Attributes returns the schema's attributes in declaration order. The returned slice is a copy; mutating it does not affect the Schema.

func (Schema) Filterable

func (s Schema) Filterable(name string) bool

Filterable reports whether the named attribute may appear in Query.Filters.

func (Schema) IsZero

func (s Schema) IsZero() bool

IsZero reports whether the schema declares no attributes. A zero Schema disables validation at the engine seam (back-compat for schema-less callers).

func (Schema) Lookup

func (s Schema) Lookup(name string) (Attribute, bool)

Lookup returns the attribute with the given name and whether it exists.

func (Schema) Queryable

func (s Schema) Queryable(name string) bool

Queryable reports whether the named attribute may appear in Query.Fields.

func (Schema) Sortable

func (s Schema) Sortable(name string) bool

Sortable reports whether the named attribute may appear in Query.Sorts.

func (Schema) ValidateQuery

func (s Schema) ValidateQuery(q Query) error

ValidateQuery is the source of typed, 400-class errors. It checks every field referenced in Filters, Sorts, and Fields against the schema's capabilities and returns a typed error (wrapping the offending field name) on the first violation. A zero Schema validates nothing (back-compat for schema-less callers); see Schema.IsZero.

Dotted ("relation.path") field names are skipped here: they reference another entity and are validated by the engine against the target schema (TODO), not the root schema. Relationship ("has") filters are likewise skipped.

func (Schema) With

func (s Schema) With(overrides ...Attribute) Schema

With returns a new Schema with the given attributes layered on top: an override whose Name matches an existing attribute replaces it in place; a new Name is appended. The receiver is left unchanged.

Use it for what tags cannot express — computed attributes, relation labels, tightened operator sets — keeping the Schema immutable.

func (Schema) Writable

func (s Schema) Writable(name string, op WriteOp) bool

Writable reports whether the named attribute may be written by the given op.

type SchemaOption

type SchemaOption func(*schemaConfig)

SchemaOption customizes schema derivation.

func WithSchemaNaming

func WithSchemaNaming(n NamingConfig) SchemaOption

WithSchemaNaming overrides the well-known field names (created_at, updated_at, deleted_at) used to derive the read-only timestamp/soft-delete defaults. By default the standard names apply.

type SortDirection

type SortDirection int8

SortDirection represents a direction of sorting.

const (
	// SortDirectionUnspecified means that direction is not specified by user and will be fallback to default.
	SortDirectionUnspecified SortDirection = iota
	// SortDirectionAsc means that sorting will be done in ascending order.
	SortDirectionAsc
	// SortDirectionDesc means that sorting will be done in descending order.
	SortDirectionDesc
)

func (SortDirection) String

func (s SortDirection) String() string

String is implemented for debugging purposes, so the SortDirection is a fmt.Stringer.

type SortNullsPosition

type SortNullsPosition int8

SortNullsPosition represents a position of nulls in sort (first or last).

const (
	// NullsPositionNotSpecified means that nulls position won't be specified in the query.
	NullsPositionNotSpecified SortNullsPosition = iota

	// NullsPositionFirst means that nulls will be placed first in the result.
	NullsPositionFirst

	// NullsPositionLast means that nulls will be placed last in the result.
	NullsPositionLast
)

func (SortNullsPosition) String

func (s SortNullsPosition) String() string

String is implemented for debugging purposes, so the SortNullsPosition is a fmt.Stringer.

type SortSpec

type SortSpec struct {
	Column    *FieldSpec
	Direction SortDirection // Asc | Desc | Unspecified (default)

	NullsPosition SortNullsPosition // First | Last | Unspecified
}

SortSpec represents a single sort criteria.

func NewSortAscSpec

func NewSortAscSpec(col *FieldSpec) *SortSpec

NewSortAscSpec returns sort by a given field ordered ASC.

func NewSortDescSpec

func NewSortDescSpec(col *FieldSpec) *SortSpec

NewSortDescSpec returns sort by a given field ordered DESC.

func NewSortSpec

func NewSortSpec(col *FieldSpec, direction SortDirection, nullsPositionArg ...SortNullsPosition) *SortSpec

NewSortSpec is the main SortSpec constructor.

func (*SortSpec) Clone

func (s *SortSpec) Clone() *SortSpec

Clone returns a new pointer to the sort spec (safe and deep clone).

func (*SortSpec) GetCriteria

func (s *SortSpec) GetCriteria() *FieldSpec

GetCriteria returns the sort criteria.

func (*SortSpec) GetDirection

func (s *SortSpec) GetDirection() SortDirection

GetDirection returns the sort direction.

func (*SortSpec) String

func (s *SortSpec) String() string

String returns string representation of the sort spec.

type Sorts

type Sorts []*SortSpec

Sorts represents a list of *SortSpec.

func (Sorts) Clone

func (sorts Sorts) Clone() Sorts

Clone returns a cloned list of given sorts.

func (Sorts) MergeWith

func (sorts Sorts) MergeWith(other Sorts) Sorts

MergeWith merges other sorts into ours.

type Transactor

type Transactor[T any, ID comparable] interface {
	// BeginTx starts a transaction and returns a [TxCRUD] scoped to that transaction.
	// All operations on the returned TxCRUD execute within the transaction.
	// The caller MUST call either Commit or Rollback on the returned TxCRUD.
	BeginTx(ctx context.Context) (TxCRUD[T, ID], error)
}

Transactor is an optional interface that CRUD implementations may satisfy to indicate transaction support. Not every backend supports transactions (e.g. in-memory stores, YAML files, some NoSQL), so this is opt-in.

Use a type assertion to check whether a repository supports transactions:

txr, ok := repo.(r3.Transactor[MyEntity, int64])
if !ok {
    // transactions not supported
}

Or use the InTx helper for ergonomic usage with automatic commit/rollback.

type TxCRUD

type TxCRUD[T any, ID comparable] interface {
	CRUD[T, ID]

	// Commit commits the transaction.
	// After Commit, the TxCRUD must not be used.
	Commit() error

	// Rollback aborts the transaction.
	// Rollback is a no-op if Commit was already called.
	// After Rollback, the TxCRUD must not be used.
	Rollback() error
}

TxCRUD is a transactional CRUD that operates within a single transaction. It embeds CRUD for all standard operations and adds Commit/Rollback to control the transaction lifecycle.

After Commit or Rollback is called, the TxCRUD should not be used for further operations.

func BeginTxChain

func BeginTxChain[T any, ID comparable](ctx context.Context, repo CRUD[T, ID]) (TxCRUD[T, ID], error)

BeginTxChain begins a transaction on the backend Transactor reached by walking repo's decorator chain, and returns a TxCRUD whose CRUD methods are the same decorator stack re-applied on top of the transaction. Commit and Rollback drive the underlying backend transaction.

It is the chain-aware counterpart to calling BeginTx directly on a backend, and is what decorators delegate to so transactions compose with decoration. Returns ErrTransactionsNotSupported if no layer implements Transactor.

type Unwrapper

type Unwrapper[T any, ID comparable] interface {
	Unwrap() CRUD[T, ID]
}

Unwrapper is implemented by decorators that wrap an inner CRUD. Exposing the wrapped CRUD lets capability detection (As) and transaction propagation (InTx, BeginTxChain) walk the decorator chain down to the backend.

Every r3 feature decorator implements Unwrapper.

type WriteOp

type WriteOp uint8

WriteOp identifies which write a capability check applies to.

const (
	// WriteOpCreate is a Create — gated by the Creatable capability.
	WriteOpCreate WriteOp = iota
	// WriteOpMutate is an Update or Patch — gated by the Mutable capability.
	WriteOpMutate
)

Directories

Path Synopsis
dialects
bson
Package r3bson provides a BSON dialect for converting r3 types to MongoDB BSON documents.
Package r3bson provides a BSON dialect for converting r3 types to MongoDB BSON documents.
canonical
Package canonical provides shared parse and format functions for the canonical string representations of r3 query components.
Package canonical provides shared parse and format functions for the canonical string representations of r3 query components.
json
Package r3json provides bidirectional conversion between r3 query types and JSON.
Package r3json provides bidirectional conversion between r3 query types and JSON.
schema
Package r3schema serializes an r3.Schema to a stable, versioned, public-only JSON shape for introspection.
Package r3schema serializes an r3.Schema to a stable, versioned, public-only JSON shape for introspection.
sql
Package r3sql translates r3 query types into SQL clauses.
Package r3sql translates r3 query types into SQL clauses.
toml
Package r3toml provides bidirectional conversion between r3 query types and TOML.
Package r3toml provides bidirectional conversion between r3 query types and TOML.
url
Package r3url provides bidirectional conversion between r3 query types and URL query parameters.
Package r3url provides bidirectional conversion between r3 query types and URL query parameters.
yaml
Package r3yaml provides bidirectional conversion between r3 query types and YAML.
Package r3yaml provides bidirectional conversion between r3 query types and YAML.
drivers
bun
Package r3bun provides an r3.CRUD[T, ID] driver backed by Bun, a SQL-first Go ORM for PostgreSQL, MySQL, MSSQL, and SQLite.
Package r3bun provides an r3.CRUD[T, ID] driver backed by Bun, a SQL-first Go ORM for PostgreSQL, MySQL, MSSQL, and SQLite.
gopg
Package r3gopg provides an r3.CRUD[T, ID] driver backed by go-pg v10, a PostgreSQL ORM with a focus on PostgreSQL-specific features.
Package r3gopg provides an r3.CRUD[T, ID] driver backed by go-pg v10, a PostgreSQL ORM with a focus on PostgreSQL-specific features.
gorm
Package r3gorm provides an r3.CRUD[T, ID] driver backed by GORM.
Package r3gorm provides an r3.CRUD[T, ID] driver backed by GORM.
mongo
Package r3mongo provides an r3.CRUD[T, ID] driver backed by the official MongoDB Go driver v2.
Package r3mongo provides an r3.CRUD[T, ID] driver backed by the official MongoDB Go driver v2.
mysql
Package r3mysql provides an r3.CRUD[T, ID] driver backed by go-sql-driver/mysql, the pure Go MySQL driver for database/sql.
Package r3mysql provides an r3.CRUD[T, ID] driver backed by go-sql-driver/mysql, the pure Go MySQL driver for database/sql.
pgx
Package r3pgx provides an r3.CRUD[T, ID] driver backed by jackc/pgx, the pure Go PostgreSQL driver.
Package r3pgx provides an r3.CRUD[T, ID] driver backed by jackc/pgx, the pure Go PostgreSQL driver.
pq
Package r3pq provides an r3.CRUD[T, ID] driver backed by lib/pq, the pure Go PostgreSQL driver for database/sql.
Package r3pq provides an r3.CRUD[T, ID] driver backed by lib/pq, the pure Go PostgreSQL driver for database/sql.
sqlite3
Package r3sqlite3 provides an r3.CRUD[T, ID] driver backed by mattn/go-sqlite3, the CGo SQLite3 driver for database/sql.
Package r3sqlite3 provides an r3.CRUD[T, ID] driver backed by mattn/go-sqlite3, the CGo SQLite3 driver for database/sql.
engine
file
Package enginefile provides a file-based CRUD engine for r3.
Package enginefile provides a file-based CRUD engine for r3.
mongo
Package enginemongo provides the MongoDB engine for r3.
Package enginemongo provides the MongoDB engine for r3.
sql
Package enginesql provides the SQL engine for r3.
Package enginesql provides the SQL engine for r3.
examples
02petstore
Package petstore demonstrates a full CRUD JSON API server using r3 with GORM and PostgreSQL.
Package petstore demonstrates a full CRUD JSON API server using r3 with GORM and PostgreSQL.
02petstore/cmd command
Pet Store example server.
Pet Store example server.
features
history
Package history provides activity log / change tracking for r3 CRUD repositories.
Package history provides activity log / change tracking for r3 CRUD repositories.
metrics
Package metrics provides domain-level analytics for r3 CRUD repositories.
Package metrics provides domain-level analytics for r3 CRUD repositories.
permissions
Package permissions provides a policy-based, entity-aware permission decorator for r3 CRUD repositories.
Package permissions provides a policy-based, entity-aware permission decorator for r3 CRUD repositories.
softdelete
Package softdelete provides a decorator for r3.CRUD[T, ID] that adds Restore and HardDelete capabilities to any CRUD implementation that supports soft-delete.
Package softdelete provides a decorator for r3.CRUD[T, ID] that adds Restore and HardDelete capabilities to any CRUD implementation that supports soft-delete.
transactor
Package transactor provides a decorator for r3.CRUD[T, ID] that surfaces transaction capabilities from the underlying driver.
Package transactor provides a decorator for r3.CRUD[T, ID] that surfaces transaction capabilities from the underlying driver.
validation
Package validation provides a decorator that validates entities before mutation operations (Create, Update, Patch) on any r3.CRUD[T, ID] repository.
Package validation provides a decorator that validates entities before mutation operations (Create, Update, Patch) on any r3.CRUD[T, ID] repository.
internal
tag
Package r3tag provides struct tag parsing for r3 entities.
Package r3tag provides struct tag parsing for r3 entities.
utils
Package r3utils provides shared utility functions used across r3 packages.
Package r3utils provides shared utility functions used across r3 packages.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL