r3

package module
v0.2.4 Latest Latest
Warning

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

Go to latest
Published: Jul 29, 2026 License: MIT Imports: 18 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 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 Pet struct {
    ID   int64  `gorm:"primaryKey"`
    Name string
}

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

// Create
pet, err := petRepo.Create(ctx, Pet{Name: "Bella"})

// Get by ID - missing records return r3.ErrNotFound on every backend
pet, err := petRepo.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.
pets, total, err := petRepo.List(ctx, r3.Query{
    Filters: r3.Filters{
        r3.Eq("name", "Bella"),
    },
    Sorts: r3.Sorts{
        r3.NewSortAscSpec(r3.NewFieldSpec("name")),
    },
    Pagination: r3.NewPaginationSpec(1, 25),
})

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

// Update
pet.Name = "Max"
pet, err = petRepo.Update(ctx, pet)

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

// Delete
err = petRepo.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,
                      i18n, 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, Count
  • 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 - Page number + size, or a raw (offset, limit)
  • 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/when - human time vocabulary ("weekends", "mornings") into recurring time-pattern filters, bridging years

Engines and drivers consume dialects internally; most application code never imports 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. Also advertises verdicts (Allow/AllowedOps) so a frontend can render per-row capability flags computed by the same policy that enforces - no client-side drift.

  • 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).

  • i18n - Entity-content translations. Reads (Get/List) overlay translated field values for the locale carried in the context (r3.WithLocale); writes mark translations of changed source text stale for re-translation workers. The translation store is itself an r3.CRUD[Translation, string].

  • 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, WeekdayIn, TimeOfDayBetween.

Recurring time patterns

Two operators match a time field against a recurring weekly wall-clock pattern - something no Between/In combination can express, because a recurring window over a timestamp column is an infinite union of ranges:

r3.WeekdayIn("started_at", time.Saturday, time.Sunday)  // weekend rows
r3.TimeOfDayBetween("started_at", 22*60, 5*60)          // 22:00-05:00 (wraps midnight)

Both evaluate the field's stored wall-clock value as-is - no engine performs timezone conversion. Apps that need per-row locality store a local wall-clock column and filter on that. Store UTC (or a normalized local wall-clock) for identical results across backends: the in-memory engines read the time.Time's own location while Mongo reads the stored date as UTC. The keyword vocabulary that turns "weekends" or "mornings" into these operators lives in the dialects/when bridge to years, never in the core:

filters, err := r3when.Parse("started_at", "weekends,mornings")  // OR of the two

Backend support: the file engine and Mongo execute these operators today (Mongo lowers them to an indexless $expr); the SQL dialect returns a loud error until per-flavor weekday/hour extraction lands (see docs/plan-when-filters.md).

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 Pet struct {
    ID        int64     `r3:"id,pk"`                              // read-only identity
    Name      string    `r3:"name"`                               // all capabilities
    Status    string    `r3:"status,enum:available|pending|sold"` // enum + allowed values
    Slug      string    `r3:"slug,immutable"`                     // creatable once, then read-only
    Visits    int       `r3:"visits,readonly"`                    // system-tracked; users can't write
    VetNotes  string    `r3:"vet_notes,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 runs - ErrUnknownField, 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. Shaping never leaks into the return value: Update and Patch hand back the row as persisted, so a column the write skipped comes back with its stored value rather than whatever the caller passed in.

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 inventory 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, syncedPet)        // ergonomic wrapper
repo.Update(r3.WithoutWriteGuard(ctx), syncedPet)   // 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[Pet, int64](db,
    r3.WithConfig(r3.Config{Defaults: r3.DefaultsConfig{Unpaginated: true}}),
)
// repo.List(ctx) now returns all rows; individual queries can still paginate.

Consumers that think in raw row offsets (REST ?offset=, infinite-scroll loaders) can skip the page-number math and pass an offset directly. Unlike a page number, an offset can start at a row that is not a multiple of the limit - it is carried to the driver verbatim, so no rounding silently duplicates or skips rows:

r3.Query{Pagination: r3.NewOffsetPagination(100, 30)}  // exactly rows 100-129

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"))},
}

Aggregation

Grouped COUNT/COUNT DISTINCT/SUM/AVG/MIN/MAX without materializing rows, via the opt-in Aggregator capability. Declare the shape on the query (GroupBy, Aggregates, Having) and call it through r3.AggregateOf:

// Order stats per store: how many, and when the latest one was placed.
rows, err := r3.AggregateOf(ctx, orderRepo, r3.Query{
    Filters: r3.Filters{r3.Ne("store_id", nil)},
    GroupBy: r3.GroupBy("store_id", "status"),
    Aggregates: r3.Aggregates{
        r3.AggCount("orders"),
        r3.AggMax("placed_at", "last_order"),
    },
    Having: r3.Filters{r3.Gt("orders", 1)},          // filters grouped rows by alias
    Sorts:  r3.Sorts{r3.NewSortDescSpec(r3.NewFieldSpec("orders"))},
})
for _, row := range rows {
    store, _ := row.Int64("store_id")
    n, _ := row.Int64("orders")
    last, _ := row.Time("last_order") // parses backends' textual timestamps too
    fmt.Printf("store %d: %d orders, last on %s\n", store, n, last.Format(time.DateOnly))
}

Each AggregateRow carries the group-field values plus one entry per declared alias; the typed accessors (Int64, Float64, String, Time, Bool) coerce backend-native representations (SQLite returns MAX over a datetime as TEXT, MySQL returns SUM as a decimal string). Filters, IncludeTrashed, Sorts (over group fields and aliases), and Pagination (limits grouped rows) apply; an empty GroupBy returns a single whole-set row.

Every engine implements Aggregator - SQL lowers to GROUP BY/HAVING, Mongo runs a $group pipeline, the file engine folds in memory - and every feature decorator forwards it, with permissions applying its scope filters so grouped results only ever cover rows the actor may see. r3.AggregateOf deliberately checks only the outermost repo (never unwrapping decorators), so that scoping cannot be bypassed. A repo without the capability returns r3.ErrAggregateNotSupported.

Time bucketing

Grouping by a raw timestamp yields one group per distinct instant - useless for a chart. A time-bucket group key truncates a time field to a fixed calendar unit (hour, day, week, month, year) so "counts per day" pushes down to the backend:

// SELECT date(created_at) AS day, COUNT(*) AS n ... GROUP BY date(created_at)
rows, _ := r3.AggregateOf(ctx, orderRepo, r3.Query{
    Buckets:    r3.Buckets{r3.Bucket("created_at", r3.BucketDay, "day")},
    Aggregates: r3.Aggregates{r3.AggCount("n")},
    Sorts:      r3.Sorts{r3.NewSortAscSpec(r3.NewFieldSpec("day"))},
})
for _, row := range rows {
    day, _ := row.Time("day") // truncated timestamp, under the bucket's alias
    n, _ := row.Int64("n")
    fmt.Printf("%s: %d\n", day.Format(time.DateOnly), n)
}

Buckets live in Query.Buckets beside GroupBy (they participate in the same GROUP BY), and each returns under its declared alias - so AggregateRow.Time, Sorts, and Having reference it like any other column. A bucket is a closed-form primitive, the aggregation-side analog of the WeekdayIn / TimeOfDayBetween filters: it truncates the field's stored wall-clock value as-is (no timezone conversion; store UTC for identical results across backends), and week buckets start on ISO-8601 Monday on every backend.

Backend support: the file engine (in-memory truncation), Mongo ($dateTrunc), and the SQL backends via a per-flavor hook - every raw SQL driver, GORM, and go-pg. A backend without the hook (bun today) returns r3.ErrBucketNotSupported rather than silently un-bucketed rows. See docs/backend-parity.md.

Upsert & Bulk Patch

Two more opt-in write capabilities, reached through top-level helpers (like r3.AggregateOf) so feature decorators - permission scoping in particular - always apply:

// Upsert - insert, or update in place on conflict. With no options it
// conflicts on the primary key and overwrites every mutable column.
pet, err := r3.UpsertOf(ctx, petRepo, pet,
    r3.OnConflict("slug"),                        // custom conflict target
    r3.UpdateOnConflict(r3.NewFieldSpec("name")), // overwrite only these columns
)

// PatchWhere - set fields (taken from the entity) on every row matching
// the filters; returns the number of rows affected.
n, err := r3.PatchWhereOf(ctx, petRepo,
    r3.Filters{r3.Eq("status", "pending")},
    Pet{Status: "available"},
    r3.Fields{r3.NewFieldSpec("status")},
)

A backend without the capability returns r3.ErrUpsertNotSupported / r3.ErrBulkPatchNotSupported: upsert is implemented by the GORM, raw SQL, Mongo, and Bun drivers; bulk patch by GORM, raw SQL, and Mongo. See docs/backend-parity.md.

Relationships

Entities relate to each other as has-many, belongs-to, or many-to-many. Declare a relation by struct tag (r3:"rel:has-many,fk:store_id") or physically by table and column names - the latter lets an entity relate to a table it does not import as a Go type, which sidesteps domain import cycles:

// Pet relates to tags via a join table - with no Tags field on Pet,
// so package pet never imports package tag.
repo := r3gorm.NewGormCRUD[Pet, int64](db, r3.WithRelations(
    r3.ManyToManyRelation("tags", "pet_tags", "pet_id", "tag_id", "tags"),
))

Either way the relation drives three operations:

// Has - rows whose relation matches (EXISTS)
repo.List(ctx, r3.Query{Filters: r3.Filters{r3.Has("tags", r3.Eq("name", "vaccinated"))}})

// HasNo - the anti-join (NOT EXISTS): rows with no matching related row,
// correctly including rows whose foreign key is NULL
repo.List(ctx, r3.Query{Filters: r3.Filters{r3.HasNo("orders")}})

// AggregateThroughRelation - grouped aggregation over the related rows
// (a has-many child table or an m2m join table)
rows, _ := r3.AggregateThroughRelation(ctx, storeRepo, "pets", r3.Query{
    GroupBy:    r3.GroupBy("store_id"),
    Aggregates: r3.Aggregates{r3.AggCount("pets")},
})

AggregateThroughRelation interprets Filters as owner filters (so a permissions Scoper restricts which owners' related rows are folded) and excludes soft-deleted related rows when the relation declares a soft-delete column. It is reached via the RelationAggregator capability, forwarded by every decorator like Aggregator.

Backend support: relation resolution (Has/HasNo/AggregateThroughRelation) is implemented by the GORM and Mongo drivers today; other backends reject or ignore it. See docs/backend-parity.md for the tracked gap list.

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/pets?fields=id,name&sort=name:asc&page=2&page_size=25&status=available
q, err := r3url.ParseQuery(r.URL.Query(),
    r3url.WithDjangoStyleFilters("status", "name"),
)
pets, total, err := petRepo.List(ctx, q)

Opt in to ?when= recurring time-pattern filters (see Recurring time patterns) by pinning the target time column. An unknown keyword is a client error, surfaced with the list of valid terms:

// GET /api/sessions?when=weekends
q, err := r3url.ParseQuery(r.URL.Query(),
    r3url.WithWhenFilter("started_at"),
)

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 is a solo, opinionated project - but if you stumbled upon it and have ideas, questions, or bug reports, an issue is always welcome :)

License

MIT © amberpixels

Documentation

Overview

Package r3 provides a universal, backend-agnostic CRUD repository abstraction for Go: the same CRUD interface over PostgreSQL, MySQL, SQLite, MongoDB, and JSON/YAML/TOML files. Business code depends on r3, never on a concrete backend.

CRUD is generic over entity type T and primary key ID, composing Querier (Get, List, Count) and Commander (Create, Update, Patch, Delete). Depend on the narrower sub-interface when that is all you need (a read-only store needs only Querier).

Queries are immutable value types — Filters, Sorts, PaginationSpec, CursorSpec, Fields, Preloads — assembled into one Query. Query.MergeWith returns a new value, so queries from different sources (defaults, request, permission scope) combine without mutation. Build filters with the short-form helpers (Eq, Gt, In, Like, Between, ...) or, for table hints and nested paths, the FieldSpec forms (F, Fop).

Schema and capabilities

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

Capabilities are the public ceiling. Schema.ValidateQuery rejects an unknown or disallowed field with a typed error (ErrUnknownField, ErrFieldNotFilterable, ErrFieldNotSortable, ErrFieldNotQueryable) before any SQL is built, and the engine shapes writes to honor Creatable/Mutable so a full Update cannot clobber created_at or resurrect a soft-deleted row. created_at/updated_at are system-managed: read-only to callers but stamped with server time by the engine. The permissions feature only narrows the ceiling per-actor.

WithoutWriteGuard (on the context) and the SystemWriter wrapper are the audited system/worker door: they skip the capability check but not the structural floor (the PK and computed attributes stay unwritable), and history/metrics still record the write.

Errors and pagination

Get normalizes every backend's not-found condition to ErrNotFound, so errors.Is works the same across drivers. List paginates by default (PageSizeDefault items) unless passed Unpaginated; compare the returned total against the slice length to detect truncation. Count answers "how many match?" without materializing rows (only Filters and IncludeTrashed apply).

Aggregation

Aggregator is the opt-in grouped-aggregation capability: GROUP BY plus COUNT/COUNT DISTINCT/SUM/AVG/MIN/MAX over the matching records, returning AggregateRow values. Declare the shape on the Query (Query.GroupBy, Query.Aggregates, Query.Having) via GroupBy, AggCount, AggSum, AggMin, AggMax, AggAvg, AggCountDistinct, and reach it with AggregateOf. Every engine implements it and every decorator forwards it, so it survives decoration.

rows, err := r3.AggregateOf(ctx, raidRepo, r3.Query{
    GroupBy:    r3.GroupBy("location_id"),
    Aggregates: r3.Aggregates{r3.AggCount("raids"), r3.AggMax("date", "last_raid")},
})

Query.Buckets adds closed-form time-bucket group keys (Bucket with a BucketUnit: hour/day/week/month/year) for time-series grouping ("counts per day"). A bucket truncates the field's stored wall-clock as-is (no timezone conversion; ISO-Monday weeks) and returns under its alias, the aggregation-side analog of WeekdayIn/TimeOfDayBetween. The file, Mongo, and SQL engines lower it (SQL via a per-flavor date-trunc hook); a backend without the hook returns ErrBucketNotSupported.

Relationships

Relations (has-many, belongs-to, many-to-many) are declared either by struct tag (`r3:"rel:has-many,fk:city_id"`) or physically by table and column via WithRelations with HasManyRelation, BelongsToRelation, and ManyToManyRelation. The physical form lets an entity relate to a table it does not import as a Go type, avoiding domain import cycles. A declared relation supports three operations through the normal Query helpers:

  • Has — relationship filter (EXISTS): owners with at least one related row matching the inner filters.
  • HasNo — its negation (NOT EXISTS / anti-join), correctly including owners with a NULL foreign key.
  • AggregateThroughRelation — grouped aggregation over the related rows, via RelationAggregator. Owner filters (including a Scoper's) restrict which owners fold in, and soft-deleted related rows are excluded.

Relation resolution is GORM-only today; other SQL drivers reject relationship filters and the mongo/file engines ignore them (tracked in docs/backend-parity.md).

Project layout

Five layers, each depending only on the layers above:

  • r3 (this package): interfaces and the immutable query model. Zero deps.
  • dialects/: pure, stateless converters between r3 types and a format — data-store (sql, bson), serialization (json, yaml, toml, url, schema), and the when bridge that compiles human time vocabulary into recurring time-pattern filters. No I/O.
  • engine/: complete CRUD per storage category (sql, mongo, file); reflection, query building, execution.
  • drivers/: ready-to-use constructors for a specific library.
  • features/: decorators (permissions, history, metrics, validation, i18n, softdelete, transactor) that wrap any r3.CRUD regardless of backend.

Features compose across backends: the same permission checker, audit log, or metrics collector works whether the repo is PostgreSQL, MongoDB, or a YAML file — the behavioral layer is written once and applied everywhere.

Index

Constants

View Source
const (
	// PageSizeDefault is the package-wide default page size when none is 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 for cursor pagination without any sort
	// columns: keyset paging needs a stable order to page against.
	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 400-class message instead of leaking a backend driver error (a 500) once SQL is built.

View Source
var ErrAggregateNotSupported = errors.New("r3: aggregate not supported")

ErrAggregateNotSupported is returned by AggregateOf when the repository (or a decorator in its chain) does not implement Aggregator.

View Source
var ErrBucketNotSupported = errors.New("r3: time-bucket group key not supported by this backend")

ErrBucketNotSupported is returned when a backend cannot lower a time-bucket group key (e.g. a SQL flavor with no date-truncation hook wired). It is a loud failure by design: a backend never silently returns un-bucketed rows.

View Source
var ErrBulkPatchNotSupported = errors.New("r3: bulk patch not supported")

ErrBulkPatchNotSupported is returned by PatchWhereOf when the repository (or a decorator in its chain) does not implement BulkPatcher.

View Source
var ErrCodecNotSupported = errors.New("r3: value codec not supported by this backend")

ErrCodecNotSupported wraps a construction-time panic when an entity declares a codec that the target backend does not yet apply. Silently ignoring the codec would store the un-encoded value - data corruption, not graceful degradation - so such backends panic here instead (see RequireCodecSupport).

View Source
var ErrInvalidAggregate = errors.New("r3: invalid aggregate query")

ErrInvalidAggregate is returned when an aggregate query is structurally invalid: no aggregates declared, a missing/duplicate/invalid alias, an aggregate function that requires a field called without one, an alias colliding with a group field, a Having filter referencing an undeclared alias, or SUM/AVG over an attribute the schema knows to be non-numeric.

View Source
var ErrInvalidBucket = errors.New("r3: invalid time-bucket group key")

ErrInvalidBucket is returned when a time-bucket group key (BucketSpec) is invalid: a missing/invalid field, an unknown unit, an alias colliding with a group field or aggregate, a source field the schema knows to be non-temporal, or a codec'd field (bucketing a value stored in a non-temporal form, e.g. codec:unixtime, is not yet supported - see docs/plan-aggregate-buckets.md).

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 ops) when no record matches. Every backend normalizes its native "no rows"/"no documents" error to this sentinel - sql.ErrNoRows, gorm.ErrRecordNotFound, mongo.ErrNoDocuments, the file engine's internal not-found - so errors.Is detects a missing record identically across drivers.

View Source
var ErrRelationAggregateNotSupported = errors.New("relation aggregation not supported by this repository")

ErrRelationAggregateNotSupported is returned by AggregateThroughRelation when the repository does not implement RelationAggregator (relation aggregation is GORM-only today).

View Source
var ErrTransactionsNotSupported = errors.New("r3: transactions not supported by this repository")

ErrTransactionsNotSupported is returned when no layer in a repo's chain implements Transactor.

View Source
var ErrUnknownCodec = errors.New("r3: unknown codec")

ErrUnknownCodec wraps a panic from SchemaOf when a r3:"...,codec:<name>" tag names an unregistered codec. A tag typo is a deterministic developer error, so it fails loudly at derivation rather than silently leaving the field un-encoded.

View Source
var ErrUpsertNotSupported = errors.New("r3: upsert not supported")

ErrUpsertNotSupported is returned by UpsertOf when the repository (or a decorator in its chain) does not implement Upserter.

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

SystemActor is the actor used when the context carries none.

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 DecodeAggregateCodecs added in v0.0.8

func DecodeAggregateCodecs(s Schema, q Query, rows []AggregateRow) error

DecodeAggregateCodecs decodes, in place, the AggregateRow values that still carry a codec'd attribute's domain meaning: a group-by column mapping to a codec'd attribute, and a MIN/MAX over a codec'd attribute (both are real field values). SUM/AVG/COUNT are never decoded — a sum of unix seconds is not a time.Time. A NULL (e.g. MIN/MAX over an empty group) is left nil so AggregateRow.Time still reports ok=false rather than the codec's zero value.

No-op when the schema declares no codecs. Codec-aware Aggregator backends call it once before returning rows, so the "which columns decode" rule lives in one place.

func EncodeCursor

func EncodeCursor(cv CursorValues) (string, error)

EncodeCursor serializes cursor values into an opaque base64 token.

func EvalTimeOfDayBetween added in v0.1.5

func EvalTimeOfDayBetween(t time.Time, value any) (bool, error)

EvalTimeOfDayBetween reports whether t's wall-clock minute-of-day falls in the TimeOfDayBetween window [lo, hi). lo > hi wraps midnight (match m >= lo OR m < hi); lo == hi matches nothing.

func EvalWeekdayIn added in v0.1.5

func EvalWeekdayIn(t time.Time, value any) (bool, error)

EvalWeekdayIn reports whether t's wall-clock weekday is in the WeekdayIn value. In-memory engines (the file engine, the permissions matcher) call it so the membership and value-normalization rules live in exactly one place.

func ExtractBetweenBounds

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

ExtractBetweenBounds returns the low and high bounds from a between value, which must be a slice or array of exactly 2 elements.

func FieldsToStrings

func FieldsToStrings(fields Fields) []string

FieldsToStrings converts Fields to a []string of field names (nils skipped).

func FinalizeCount

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

FinalizeCount returns (entities, totalCount): the backend's paginatedCount when pagination was active, otherwise len(entities). Shared by every engine.

func FinalizeCountCursor

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

FinalizeCountCursor returns (entities, -1): keyset pagination has no total count.

func GetLocale added in v0.0.2

func GetLocale(ctx context.Context) string

GetLocale returns the locale tag from the context, or "" if none is set.

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, committing if it returns nil and rolling back on error or panic. It walks repo's decorator chain to the backend Transactor, begins there, and re-applies the same decorator stack on top of the tx-bound CRUD - so the CRUD passed to fn stays fully decorated and writes inside the transaction still run every decorator. 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 PatchWhereOf added in v0.0.4

func PatchWhereOf[T any, ID comparable](
	ctx context.Context, repo Commander[T, ID], filters Filters, entity T, fields Fields,
) (int64, error)

PatchWhereOf runs a bulk conditional update against repo if it (including any decorators, which forward the capability) implements BulkPatcher, and returns ErrBulkPatchNotSupported otherwise. Like UpsertOf it asserts only the outermost value so decorator concerns — the permissions scope confinement in particular — always apply.

func RegisterCodec added in v0.0.7

func RegisterCodec(name string, c Codec)

RegisterCodec registers a value codec under name for use in a struct tag (r3:"...,codec:<name>"). Call it during setup, before deriving schemas; it overwrites any existing registration and panics on an empty name or nil codec.

func RequireCodecSupport added in v0.0.7

func RequireCodecSupport(s Schema, backend string)

RequireCodecSupport panics if s declares any value codec, naming the attribute and backend. A backend that does not yet apply codecs calls this at construction so a declared codec fails loudly instead of silently storing the un-encoded value (corrupting data and breaking portability). backend is a short id for the message, e.g. "r3/mongo".

func SupportsTx

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

SupportsTx reports whether repo's chain reaches a backend implementing Transactor. It sees through decorators, and unlike As only the backend counts - an intermediate decorator merely exposing BeginTx is not proof.

func TimeOfDayBounds added in v0.1.5

func TimeOfDayBounds(value any) (int, int, error)

TimeOfDayBounds normalizes a TimeOfDayBetween value into its [lo, hi) minute bounds. The value is the 2-element [lo, hi] the sugar produces; bounds may arrive as int or float64 (JSON). Each must be a whole minute in 0..1440 (1440 is the exclusive end-of-day upper bound). Reusing ExtractBetweenBounds keeps the shape identical to Between.

func TruncateToBucket added in v0.1.7

func TruncateToBucket(t time.Time, u BucketUnit) time.Time

TruncateToBucket truncates t to the start of the calendar unit, in t's own location (no timezone conversion). Week starts on ISO-8601 Monday. An unknown unit returns t unchanged (structural validation rejects it before any engine reaches this). In-memory engines (the file engine) call it so the truncation rule lives in exactly one place - the analog of EvalWeekdayIn.

func UpsertOf added in v0.0.4

func UpsertOf[T any, ID comparable](
	ctx context.Context, repo Commander[T, ID], entity T, opts ...UpsertOption,
) (T, error)

UpsertOf runs an upsert against repo, or returns ErrUpsertNotSupported if it does not implement Upserter. Like AggregateOf, it asserts only the outermost value - never the decorator chain - so permission checks always apply.

func ValidateIdentifier

func ValidateIdentifier(s string) error

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

func WeekdaysValue added in v0.1.5

func WeekdaysValue(value any) ([]time.Weekday, error)

WeekdaysValue normalizes a WeekdayIn filter value into a []time.Weekday. It accepts the shapes that survive a serialization round-trip: []time.Weekday (the native sugar), []int, and []float64 (JSON decodes numbers as float64). Each element must be in 0..6 (Go's Sunday..Saturday). Every engine and dialect that lowers OperatorWeekdayIn goes through this one door so the accepted wire forms stay consistent.

func WithActor

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

WithActor attaches an Actor to the context, typically in auth middleware. Authorization data can ride along on Actor's Claims:

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

func WithLocale added in v0.0.2

func WithLocale(ctx context.Context, locale string) context.Context

WithLocale attaches a locale tag to the context, typically after language negotiation in HTTP middleware:

ctx := r3.WithLocale(r.Context(), "ru")

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 (roles,
	// tenant memberships, scopes, ...). R3 never inspects it - it only rides the
	// context so policies can read it back; the permissions feature forwards it to
	// Checker/Scoper via AccessRequest.Actor. Intentionally untyped: the
	// application owns the shape and type-asserts it. Nil for system/service actors
	// and the attribution-only features, which ignore it.
	Claims any
}

Actor is the identity performing a CRUD operation, carried in context and picked up by the metrics and history features for attribution. The zero value is the system/anonymous actor.

func GetActor

func GetActor(ctx context.Context) Actor

GetActor returns the Actor from the context, or SystemActor if none is set.

type AggregateFunc added in v0.0.3

type AggregateFunc int8

AggregateFunc identifies an aggregate function applied over a group of rows.

const (
	// AggregateCount counts rows in the group (COUNT(*) when Field is nil,
	// COUNT(field) otherwise — NULLs excluded).
	AggregateCount AggregateFunc = iota + 1
	// AggregateCountDistinct counts distinct non-NULL values of Field.
	AggregateCountDistinct
	// AggregateSum sums Field over the group.
	AggregateSum
	// AggregateAvg averages Field over the group.
	AggregateAvg
	// AggregateMin takes the minimum of Field over the group.
	AggregateMin
	// AggregateMax takes the maximum of Field over the group.
	AggregateMax
)

func (AggregateFunc) String added in v0.0.3

func (f AggregateFunc) String() string

String returns the canonical lowercase name of the aggregate function.

type AggregateRow added in v0.0.3

type AggregateRow map[string]any

AggregateRow is one result row of an Aggregate call: group-field values plus one entry per declared aggregate alias. Raw values are backend-native (SQLite returns MAX over a timestamp as TEXT, MySQL returns SUM as a decimal string), so prefer the typed accessors, which coerce the common representations.

func AggregateOf added in v0.0.3

func AggregateOf[T any, ID comparable](
	ctx context.Context, repo Querier[T, ID], qarg ...Query,
) ([]AggregateRow, error)

AggregateOf runs an aggregate query against repo if it implements Aggregator (decorators all forward it), else returns ErrAggregateNotSupported. It asserts only the outermost value - never walking the chain - so feature concerns, permission scoping above all, always apply.

func AggregateThroughRelation added in v0.0.5

func AggregateThroughRelation[T any, ID comparable](
	ctx context.Context, repo Querier[T, ID], relation string, qarg ...Query,
) ([]AggregateRow, error)

AggregateThroughRelation folds repo's related rows through the named relation, or returns ErrRelationAggregateNotSupported if repo does not implement RelationAggregator. Like AggregateOf, it asserts only the outermost value - never the decorator chain - so permission scoping is never bypassed.

func (AggregateRow) Bool added in v0.0.3

func (r AggregateRow) Bool(key string) (bool, bool)

Bool returns the value as bool, coercing the 0/1 integers SQL backends return for boolean columns.

func (AggregateRow) Float64 added in v0.0.3

func (r AggregateRow) Float64(key string) (float64, bool)

Float64 returns the value as float64, coercing integer widths and numeric strings (MySQL decimals arrive as []byte).

func (AggregateRow) Int64 added in v0.0.3

func (r AggregateRow) Int64(key string) (int64, bool)

Int64 returns the value as int64, coercing integer widths, integral floats, and numeric strings (MySQL decimals arrive as []byte).

func (AggregateRow) String added in v0.0.3

func (r AggregateRow) String(key string) (string, bool)

String returns the value as a string. []byte is converted; other types report ok=false.

func (AggregateRow) Time added in v0.0.3

func (r AggregateRow) Time(key string) (time.Time, bool)

Time returns the value as time.Time, parsing the textual forms backends fall back to when an aggregate loses the column's declared type.

func (AggregateRow) Value added in v0.0.3

func (r AggregateRow) Value(key string) any

Value returns the raw backend value for key (nil if absent).

type AggregateSpec added in v0.0.3

type AggregateSpec struct {
	Func  AggregateFunc
	Field *FieldSpec
	Alias string
}

AggregateSpec declares one aggregated value to compute per group: the function, the field it aggregates (nil only for AggregateCount = COUNT(*)), and the alias under which the value appears in each AggregateRow. The alias is also the name Sorts and Having refer to.

func AggAvg added in v0.0.3

func AggAvg(field, alias string) *AggregateSpec

AggAvg builds AVG(field) AS alias.

func AggCount added in v0.0.3

func AggCount(alias string) *AggregateSpec

AggCount counts rows per group: COUNT(*) AS alias.

func AggCountDistinct added in v0.0.3

func AggCountDistinct(field, alias string) *AggregateSpec

AggCountDistinct counts distinct values: COUNT(DISTINCT field) AS alias.

func AggMax added in v0.0.3

func AggMax(field, alias string) *AggregateSpec

AggMax builds MAX(field) AS alias.

func AggMin added in v0.0.3

func AggMin(field, alias string) *AggregateSpec

AggMin builds MIN(field) AS alias.

func AggSum added in v0.0.3

func AggSum(field, alias string) *AggregateSpec

AggSum builds SUM(field) AS alias.

func (*AggregateSpec) Clone added in v0.0.3

func (a *AggregateSpec) Clone() *AggregateSpec

Clone returns a deep copy of the spec.

type Aggregates added in v0.0.3

type Aggregates []*AggregateSpec

Aggregates is a slice of *AggregateSpec.

func (Aggregates) Clone added in v0.0.3

func (as Aggregates) Clone() Aggregates

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

type Aggregator added in v0.0.3

type Aggregator interface {
	// Aggregate computes the declared aggregates per group and returns one
	// row per group.
	Aggregate(ctx context.Context, qarg ...Query) ([]AggregateRow, error)
}

Aggregator is the opt-in grouped-aggregation capability (see AggregateOf and the package doc). Of the merged Query, Aggregate honors Filters, IncludeTrashed, GroupBy, Buckets, Aggregates, Having, Sorts (over group fields, bucket aliases, and aggregate aliases only - other sorts, e.g. inherited defaults, are dropped), and Pagination (limits grouped rows); Fields, Preloads, and Cursor are ignored. Empty GroupBy and Buckets with non-empty Aggregates yields a single whole-set row; empty Aggregates is ErrInvalidAggregate.

Reach it via AggregateOf, never by asserting an inner repo - unwrapping the decorator chain would silently bypass permission scoping.

type Attribute

type Attribute struct {
	// Name is the public/wire name (snake_case). Queries 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.
	Caps Capability

	// Ops are the allowed filter operators. 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

	// Codec, when non-nil, transforms this attribute's value to and from its
	// stored form (see [Codec]). Type stays the domain type (e.g. TypeTime) so
	// callers filter with domain values; the codec bridges to storage
	// ([Codec.Stored], e.g. TypeInt) at the engine boundary. nil is the identity.
	Codec Codec

	// 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.
	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 BucketSpec added in v0.1.7

type BucketSpec struct {
	Field *FieldSpec
	Unit  BucketUnit
	Alias string
}

BucketSpec is a derived group key: the truncation of a time-valued field to Unit, appearing in each AggregateRow under Alias (also the name Sorts and Having refer to). It participates in GROUP BY alongside Query.GroupBy fields.

func Bucket added in v0.1.7

func Bucket(field string, unit BucketUnit, alias string) *BucketSpec

Bucket builds a time-bucket group key: the value of field truncated to unit, returned under alias.

r3.Query{Buckets: r3.Buckets{r3.Bucket("created_at", r3.BucketDay, "day")}}

func (*BucketSpec) Clone added in v0.1.7

func (b *BucketSpec) Clone() *BucketSpec

Clone returns a deep copy of the spec.

type BucketUnit added in v0.1.7

type BucketUnit int8

BucketUnit is the closed set of calendar truncation units for a time-bucket group key (see BucketSpec).

const (
	// BucketHour truncates to the top of the hour.
	BucketHour BucketUnit = iota + 1
	// BucketDay truncates to midnight of the day.
	BucketDay
	// BucketWeek truncates to midnight of the ISO-8601 week start (Monday).
	BucketWeek
	// BucketMonth truncates to midnight of the first day of the month.
	BucketMonth
	// BucketYear truncates to midnight of January 1st.
	BucketYear
)

func (BucketUnit) String added in v0.1.7

func (u BucketUnit) String() string

String returns the canonical lowercase name of the unit (also the Mongo $dateTrunc unit name).

func (BucketUnit) Valid added in v0.1.7

func (u BucketUnit) Valid() bool

Valid reports whether u is one of the defined bucket units.

type Buckets added in v0.1.7

type Buckets []*BucketSpec

Buckets is a slice of *BucketSpec.

func (Buckets) Clone added in v0.1.7

func (bs Buckets) Clone() Buckets

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

type BulkPatcher added in v0.0.4

type BulkPatcher[T any, ID comparable] interface {
	// PatchWhere writes fields (taken from entity) on every row matching
	// filters and returns the number of rows affected.
	PatchWhere(ctx context.Context, filters Filters, entity T, fields Fields) (int64, error)
}

BulkPatcher is the optional bulk-update capability of a repository: set the named fields (to the entity's values) on every row matching filters, returning the affected-row count. It is the multi-row analogue of Patch — same partial, by-Fields semantics — but selects rows by Filters instead of by primary key.

Like Upserter it is opt-in and reached through PatchWhereOf, so feature decorators (permission scoping in particular) always apply. A backend that does not implement it surfaces ErrBulkPatchNotSupported from PatchWhereOf rather than panicking.

type CRUD

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

CRUD is the full read+write repository interface, composing Querier and Commander.

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 Codec added in v0.0.7

type Codec interface {
	// Encode converts a Go field value into the value handed to the backend. It
	// returns (nil, nil) to store NULL — e.g. the zero time.Time or a nil pointer.
	Encode(goValue any) (any, error)

	// Decode converts a stored value back into the Go field of type target (a
	// pointer type for a nullable field), tolerating the representation variance
	// across backends (int64/int32/float64/[]byte/string) and mapping NULL to the
	// zero value or nil pointer. A nil target means no destination shape is known
	// (e.g. an [AggregateRow] has no struct field): decode to the codec's natural
	// domain type, which [DecodeAggregateCodecs] relies on.
	Decode(stored any, target reflect.Type) (any, error)

	// Stored reports the logical DataType of the stored form (e.g. [TypeInt] for
	// unixtime) — a hint for bind typing and cursor encoding, not a substitute for
	// the attribute's domain [DataType].
	Stored() DataType
}

Codec transforms a Go field value to and from its stored representation. It is declared once per attribute via the r3:"...,codec:<name>" struct tag, then applied uniformly by every codec-aware backend: on write (Go → stored), on read (stored → Go), and on filter/cursor arguments (a domain value is encoded before it reaches the query). The flagship is time.Time ⇄ unix int ("unixtime"), but the abstraction is general — money as cents, enums as ints, a struct as JSON.

A Codec operates on plain Go values, so it is backend-neutral: an engine feeds it whatever it scanned (int64, int32, float64, []byte, string) and takes back a value to bind or marshal. Implementations MUST be pure and stateless — one instance is shared across all repositories and cached on the Schema. A codec never owns the physical column type (r3 does no DDL); the column must already be the type Codec.Stored reports.

func LookupCodec added in v0.0.9

func LookupCodec(name string) (Codec, bool)

LookupCodec resolves a registered value codec by its tag name (e.g. "unixtime"). It lets an engine that derives field metadata reflectively - rather than through the generic SchemaOf - build its own codec map: schema derivation resolves the tag name to a Codec, but a backend keyed on a physical field name (Mongo's bson name, say) needs to resolve it independently. Returns false for an unregistered name.

type Commander

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

	// Update modifies an existing record and returns it as persisted. Columns the
	// backend does not write (immutable ones like created_at) come back with
	// their stored values, not the caller's; relation fields are returned as
	// passed in, since a read-back does not load them.
	Update(context.Context, T) (T, error)

	// Patch partially updates the row (PK must be set), writing only the columns
	// named in Fields and returning the full entity.
	Patch(context.Context, T, Fields) (T, error)

	// Delete removes a record by ID (soft-deletes if enabled on the repository).
	Delete(context.Context, ID) error
}

Commander is the write-only subset of repository operations.

type Config

type Config struct {
	// Naming 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 (naming, default query behavior) shared across models. Build it with DefaultConfig, override fields, then pass it to a constructor via WithConfig. Treat as read-only after construction.

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" position.
	CursorForward CursorDirection = iota
	// CursorBackward pages backward from the "before" 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 max results to return; 0 means the default.
	Limit int
}

CursorSpec specifies cursor/keyset pagination. Set at most one of After or Before; if both are set, After wins.

func NewCursorAfter

func NewCursorAfter(after string, limit int) *CursorSpec

NewCursorAfter creates a CursorSpec for forward pagination after the token.

func NewCursorBefore

func NewCursorBefore(before string, limit int) *CursorSpec

NewCursorBefore creates a CursorSpec for backward pagination before the token.

func NewCursorFirst

func NewCursorFirst(limit int) *CursorSpec

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

func (*CursorSpec) Clone

func (c *CursorSpec) Clone() *CursorSpec

Clone returns 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 unset.

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 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 (column name -> value) that define a cursor 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.

func EncodeCursorCodecs added in v0.0.7

func EncodeCursorCodecs(s Schema, values CursorValues) (CursorValues, error)

EncodeCursorCodecs clones the decoded cursor values with every codec'd key converted to stored form, so keyset predicates compare against stored column values. No-op when the schema declares no codecs.

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 holds the default queries for List and Get, shared across all drivers.

func NewDefaults

func NewDefaults() Defaults

NewDefaults returns Defaults with reasonable default queries.

type DefaultsConfig

type DefaultsConfig struct {
	// PageSize is the default page size when pagination is active but no explicit
	// size is given. Default: [PageSizeDefault].
	PageSize int

	// Unpaginated, when true, makes List return ALL matching rows by default (no
	// page-size cap); a per-query Query.Pagination still opts back in. Takes
	// precedence over PageSize.
	//
	// Use with care on large tables; prefer the per-query [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 is thread-safe access to Defaults; embed it in a CRUD struct to get the Set/Get default-query methods 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 honoring cfg: Unpaginated makes the default list query unbounded; otherwise a non-default PageSize seeds the default list query's 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 names a field: a plain string (a column/attribute name, possibly a dotted path).

func NewFieldSpec

func NewFieldSpec(s string) *FieldSpec

NewFieldSpec returns a *FieldSpec for s.

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 GroupBy added in v0.0.3

func GroupBy(fields ...string) Fields

GroupBy builds the Fields list for Query.GroupBy from plain field names:

r3.Query{GroupBy: r3.GroupBy("location_id", "squad_id"), ...}

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 is a filter operator. This is the full set r3 aims to support; not every dialect supports every one. 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)
	OperatorWeekdayIn                           // weekday_in: weekday-of(field) ∈ value (see r3_filter_timepattern.go)
	OperatorTimeOfDayBetween                    // tod_between: minute-of-day-of(field) ∈ [lo, hi)
)

func (*FilterOperatorSpec) String

func (op *FilterOperatorSpec) String() string

String returns a debug label for the operator. Note: Protect with the `exhausted` linter.

type FilterSpec

type FilterSpec struct {
	Field    *FieldSpec         `json:"Field"`
	Operator FilterOperatorSpec `json:"Operator"`
	Value    any                `json:"Value"`

	And Filters `json:"And"`
	Or  Filters `json:"Or"`

	// Relation, when non-empty, makes this a relationship ("has") filter matching
	// rows whose declared relation (by preload struct-field name) has at least one
	// related row satisfying RelationFilter. Field/Operator/Value/And/Or are then
	// ignored. The driver lowers it to a key-set In filter before SQL translation,
	// so it works on any backend regardless of native subquery support.
	// omitempty keeps a non-relationship filter serializing exactly as before.
	Relation       string  `json:"Relation,omitempty"`
	RelationFilter Filters `json:"RelationFilter,omitempty"`

	// RelationNegate inverts a relationship filter (see [HasNo]): match rows whose
	// relation has NO related row satisfying RelationFilter - an anti-join /
	// NOT EXISTS. Only meaningful with Relation set; the driver lowers it to a
	// NOT-IN key set.
	RelationNegate bool `json:"RelationNegate,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 shorthand for NewFilterSpec ("F" for filter, "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 (by preload struct-field name) has at least one related row satisfying all of inner, evaluated against the related entity. Example:

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

The driver resolves the relation to a key set and rewrites this to an In filter, so it works on every backend regardless of native subquery support. It does not round-trip through the serialization dialects - build it in Go. As a permission scope, enforcing it on Get requires permissions.WithIDFunc.

func HasNo added in v0.0.5

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

HasNo is the negated (anti-join) counterpart of Has: it matches rows whose declared relation has NO related row satisfying inner - a NOT EXISTS. With no inner filters it matches rows with no related row at all. Example:

r3.HasNo("Translations")                 // rows with no translation yet
r3.HasNo("Squads", r3.Eq("archived", true)) // rows in no archived squad

Like Has, the driver resolves it to a key set (a NOT-IN filter); it works on every backend and does not round-trip through the serialization dialects.

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

NewFilterSpecAndGroup wraps filters in an AND group.

func NewFilterSpecOrGroup

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

NewFilterSpecOrGroup wraps filters in an OR group.

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 TimeOfDayBetween added in v0.1.5

func TimeOfDayBetween(field string, loMin, hiMin int) *FilterSpec

TimeOfDayBetween matches rows whose time field's minute-of-day lies in [loMin, hiMin). Bounds are minutes from midnight. loMin > hiMin means the window wraps midnight (e.g. 1320, 300 = 22:00-05:00). Minute precision matches schedule.TimeOfDay and sidesteps seconds edge-cases. The value mirrors Between ([]any{lo, hi}) so it round-trips through every serialization dialect as a plain 2-element array.

func WeekdayIn added in v0.1.5

func WeekdayIn(field string, days ...time.Weekday) *FilterSpec

WeekdayIn matches rows whose time field's weekday is one of days. Weekdays use Go's numbering (time.Sunday=0 .. time.Saturday=6); every lowering converts to its backend's convention at the edge (e.g. Mongo's $dayOfWeek is 1..7).

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 renders the filter as JSON.

type Filters

type Filters []*FilterSpec

Filters represents a list of *FilterSpec.

func EncodeFilterCodecs added in v0.0.7

func EncodeFilterCodecs(s Schema, filters Filters) (Filters, error)

EncodeFilterCodecs clones filters with every codec'd argument converted to stored form, so a domain value compares against stored column values (e.g. r3.Lt("started_at", someTime) against an int column). Scalar, In/NotIn slice, and Between-pair arguments are handled; operators carrying no comparable value (Exists, Like), non-codec'd fields, relationship filters, and dotted field names pass through. No-op when the schema declares no codecs.

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 combines these filters with other.

type JSONColumn

type JSONColumn[T any] struct {
	Val T
}

JSONColumn wraps a value of type T so it persists as a JSON string in a single SQL column while staying transparent to JSON APIs: it implements sql.Scanner, driver.Valuer, json.Marshaler, and json.Unmarshaler. Use it for fields stored as a JSON blob (e.g. []FieldChange, metadata maps, nested config).

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 creation-timestamp column. Default: "created_at".
	CreatedAtField string

	// UpdatedAtField is the update-timestamp column. Default: "updated_at".
	UpdatedAtField string

	// DeletedAtField is the soft-delete-timestamp column. Default: "deleted_at".
	DeletedAtField string
}

NamingConfig maps well-known fields to storage column names. Empty means "use the default".

type Option

type Option func(*Options)

Option is a functional option passed to engine/driver constructors.

func WithConfig

func WithConfig(cfg Config) Option

WithConfig sets the R3 framework-level configuration.

func WithRelations added in v0.0.5

func WithRelations(rels ...RelationSpec) Option

WithRelations registers relations by table and column name (see RelationSpec), letting a repository resolve relations to tables it does not import as Go types. These supplement the relations reflected from `r3:"rel:..."` tags, and a declared relation whose name matches a reflected one takes precedence. Resolvable through Has/HasNo filters and AggregateThroughRelation.

repo := r3gorm.NewGormCRUD[Photo, int64](db,
	r3.WithRelations(
		r3.ManyToManyRelation("locations",
			"photo_locations", "photo_id", "location_id", "locations"),
	),
)

Relation support is currently implemented by the GORM and Mongo drivers.

type Options

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

	// Relations holds relations declared explicitly (by table + column names)
	// via [WithRelations], to supplement or override the relations reflected
	// from `r3:"rel:..."` struct tags. See [RelationSpec].
	Relations []RelationSpec
}

Options is the resolved repository configuration; constructors build it from functional options via ResolveOptions.

func DefaultOptions

func DefaultOptions() Options

DefaultOptions returns Options with sensible defaults.

func ResolveOptions

func ResolveOptions(opts ...Option) Options

ResolveOptions applies functional options over DefaultOptions.

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
	// Offset, when set, is the raw 0-based row offset - an alternative to PageNum
	// for consumers that natively think in offsets (REST ?offset=, infinite-scroll
	// loaders). Unlike a page number it can express a starting row that is not a
	// multiple of PageSize. When set it takes precedence over PageNum in
	// ToLimitOffset and is carried to the driver verbatim (no page rounding).
	// PageNum and Offset are mutually exclusive position encodings (see MergeWith).
	Offset 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 NewOffsetPagination added in v0.1.4

func NewOffsetPagination(offset int, limit ...int) *PaginationSpec

NewOffsetPagination creates a PaginationSpec from a raw 0-based row offset (and an optional limit), for consumers that paginate by offset rather than page number (REST ?offset=, infinite-scroll loaders). Unlike a page number, an offset can start at a row that is not a multiple of the limit: NewOffsetPagination(100, 30) fetches exactly rows 100-129. The offset is carried to the driver verbatim, so no page rounding can silently duplicate or skip rows. A negative offset is treated as 0; an omitted limit defaults to PageSizeDefault (mirrors NewPaginationSpec).

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 disables the default page-size cap so List returns every matching record - otherwise a "give me everything" caller silently gets a truncated slice. It overrides any configured default page size on merge:

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

To make a whole repo unpaginated by default, set Config.Defaults.Unpaginated. It 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) GetOffset added in v0.1.4

func (p *PaginationSpec) GetOffset() (int, bool)

GetOffset returns the raw row offset when one was set explicitly (negative clamped to 0). The bool reports whether an explicit offset is present; when false the caller should derive the offset from PageNum instead.

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 the spec to Limit/Offset for database queries.

type PreloadSpec

type PreloadSpec struct {
	Name string
}

PreloadSpec names a relation to preload (a table/collection name).

func NewPreloadSpec

func NewPreloadSpec(name string) *PreloadSpec

NewPreloadSpec creates a PreloadSpec.

func (*PreloadSpec) Clone

func (t *PreloadSpec) Clone() *PreloadSpec

Clone returns a copy of the preload.

func (*PreloadSpec) GetName

func (t *PreloadSpec) GetName() string

GetName returns the preload name.

func (*PreloadSpec) String

func (t *PreloadSpec) String() string

String returns the preload name.

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, normalizing not-found to [ErrNotFound].
	Get(context.Context, ID, ...Query) (T, error)

	// List retrieves records matching the query.
	List(context.Context, ...Query) ([]T, int64, error)

	// Count returns how many records match, without materializing rows. Only
	// Filters and IncludeTrashed apply; pagination/sorts/fields/preloads are
	// ignored, and no query counts every non-trashed record.
	Count(context.Context, ...Query) (int64, error)
}

Querier is the read-only subset of repository operations. Depend on it when read-only access is all you need.

type Query

type Query struct {
	Pagination *PaginationSpec

	// Cursor enables keyset/cursor pagination instead of offset-based. When set it
	// 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

	// GroupBy, Buckets, Aggregates, and Having describe an aggregation honored
	// only by Aggregate (see [Aggregator]); Get/List/Count ignore them, as Count
	// ignores pagination. GroupBy names the plain grouping fields (empty = one
	// whole-set row when Buckets is also empty), Buckets adds derived time-bucket
	// group keys (see [BucketSpec]), Aggregates the computed values (required for
	// Aggregate), and Having filters grouped rows by aggregate alias, group field,
	// or bucket alias.
	GroupBy    Fields
	Buckets    Buckets
	Aggregates Aggregates
	Having     Filters

	// IncludeTrashed, when true, also returns soft-deleted records.
	IncludeTrashed maybe.Bool
}

Query is the immutable read model: filters, sorts, projection, pagination, and (for Aggregator) grouping. Compose with Query.MergeWith; never mutate a shared value.

func DefaultQuery

func DefaultQuery() Query

DefaultQuery returns a Query with the default pagination applied.

func NewQuery

func NewQuery() Query

NewQuery returns an empty Query.

func (Query) AggregateSorts added in v0.0.3

func (q Query) AggregateSorts() Sorts

AggregateSorts restricts the query's Sorts to what a grouped result can be ordered by - group fields and aggregate aliases. Anything else (typically a default sort over entity columns absent from grouped rows) is dropped. Engines call this instead of Query.Sorts directly.

func (Query) Clone

func (q Query) Clone() Query

Clone clones the query.

func (Query) MergeWith

func (q Query) MergeWith(other Query) Query

MergeWith returns a new Query combining q with other (no mutation). Fields, Filters, and Preloads accumulate (union). Sorts and Pagination OVERRIDE - other is the higher-precedence layer, typically a per-call query over a repo's defaults.

type RelationAggregator added in v0.0.5

type RelationAggregator interface {
	// AggregateThroughRelation folds the declared relation's related rows into
	// one AggregateRow per group.
	AggregateThroughRelation(ctx context.Context, relation string, qarg ...Query) ([]AggregateRow, error)
}

RelationAggregator is the opt-in capability of aggregating an entity's RELATED rows through a declared relation: grouped COUNT/SUM/AVG/MIN/MAX like Aggregator, but folded over the related rows (a has-many child table or a many-to-many join table) rather than the entity's own - e.g. "members per group" over a membership join table, without dropping to raw SQL.

Query semantics (of the merged Query):

  • GroupBy and Aggregates resolve against the RELATED base table: the child table for has-many, the join table for many-to-many. An AggCount with no field counts related rows; empty Aggregates is ErrInvalidAggregate.
  • Filters are interpreted as filters on the OWNER entity and restrict which owners' related rows are aggregated (so a permissions Scoper's owner filters apply). Empty Filters aggregates across all owners.
  • Related rows soft-deleted in the target are excluded when the relation declares a soft-delete column (see RelationTargetSoftDelete or a tag-derived soft-delete on the target).
  • Having, Sorts (group fields + aggregate aliases only), and Pagination behave as in Aggregator. IncludeTrashed, Fields, Preloads, and Cursor are ignored.

Only has-many and many-to-many relations are aggregatable; belongs-to is not (each owner has at most one related row). Reach it via AggregateThroughRelation rather than asserting inner repos, so permission scoping is never bypassed.

type RelationKind added in v0.0.5

type RelationKind uint8

RelationKind identifies the shape of a relation between two entities.

const (
	// RelationHasMany is one-to-many: the child rows carry an FK to the owner.
	RelationHasMany RelationKind = iota + 1
	// RelationBelongsTo is many-to-one: the owner row carries an FK to the related row.
	RelationBelongsTo
	// RelationManyToMany is resolved through a join table.
	RelationManyToMany
)

func (RelationKind) String added in v0.0.5

func (k RelationKind) String() string

String returns the canonical lowercase name matching the `r3:"rel:..."` tag vocabulary ("has-many" | "belongs-to" | "many-to-many").

type RelationOption added in v0.0.5

type RelationOption func(*RelationSpec)

RelationOption customizes an optional field of a RelationSpec.

func RelationTargetPK added in v0.0.5

func RelationTargetPK(column string) RelationOption

RelationTargetPK overrides the related table's primary-key column (default "id").

func RelationTargetSoftDelete added in v0.0.5

func RelationTargetSoftDelete(column string) RelationOption

RelationTargetSoftDelete declares the related table's soft-delete column so AggregateThroughRelation excludes soft-deleted related rows.

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 target attribute 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 RelationSpec added in v0.0.5

type RelationSpec struct {
	// Name is the logical name used by Has/HasNo and AggregateThroughRelation;
	// it need not correspond to any struct field.
	Name string

	// Kind is the relation shape.
	Kind RelationKind

	// TargetTable is the related rows' table (e.g. "locations").
	TargetTable string

	// TargetPK is the related table's primary-key column. Defaults to "id".
	TargetPK string

	// FKColumn is the foreign-key column:
	//   - has-many:     the FK on the related (child) table pointing at the owner
	//   - belongs-to:   the FK on the owner table pointing at the related row
	//   - many-to-many: the owner-side FK column in the join table
	FKColumn string

	// RefColumn is the related-side FK column in the join table (many-to-many only).
	RefColumn string

	// JoinTable is the join table name (many-to-many only).
	JoinTable string

	// TargetSoftDeleteColumn, when set, is the related table's soft-delete
	// column: [AggregateThroughRelation] excludes related rows whose value is
	// non-NULL, so soft-deleted related rows are not counted.
	TargetSoftDeleteColumn string
}

RelationSpec physically describes a relation by table and column names, so a driver can resolve it WITHOUT importing the related Go type - the explicit counterpart to a tag-declared relation (`r3:"rel:..."`). This lets an entity relate to a table it cannot import (e.g. when the packages already reference each other, so importing would create a domain cycle); being a value rather than a struct field, it needs no Go field on the entity.

Register specs with WithRelations; they then resolve by name through Has/HasNo and AggregateThroughRelation like tag-declared relations, but are not preloadable (no struct field to load into). Build one with HasManyRelation, BelongsToRelation, or ManyToManyRelation.

func BelongsToRelation added in v0.0.5

func BelongsToRelation(name, targetTable, fkColumn string, opts ...RelationOption) RelationSpec

BelongsToRelation declares a many-to-one relation: the owner's `fkColumn` points at `targetTable`'s primary key.

func HasManyRelation added in v0.0.5

func HasManyRelation(name, targetTable, fkColumn string, opts ...RelationOption) RelationSpec

HasManyRelation declares a one-to-many relation: `fkColumn` on `targetTable` points back at the owner's primary key.

func ManyToManyRelation added in v0.0.5

func ManyToManyRelation(
	name, joinTable, fkColumn, refColumn, targetTable string, opts ...RelationOption,
) RelationSpec

ManyToManyRelation declares a many-to-many relation through `joinTable`: `fkColumn` is the owner-side FK and `refColumn` the related-side FK.

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 [Attribute]s. It carries no storage details (no column names, no BSON paths) - each engine owns the physical binding, keyed by attribute name.

A Schema is immutable: derive with SchemaOf, layer overrides with Schema.With, both returning a fresh value. The zero Schema has no attributes and validates nothing (back-compat for schema-less callers); see Schema.IsZero.

Vocabulary: an Attribute is a declared member of an entity; a FieldSpec is a reference to one inside a Query. The Schema is the set of valid fields plus what each may do.

func SchemaOf

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

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

func (Schema) Attributes

func (s Schema) Attributes() []Attribute

Attributes returns the attributes in declaration order (a copy).

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) ValidateAggregateQuery added in v0.0.3

func (s Schema) ValidateAggregateQuery(q Query) error

ValidateAggregateQuery is the aggregate counterpart of Schema.ValidateQuery, called by engines before building an aggregate. Structural checks (aggregates declared, aliases valid and unique, Having references declared names) run even for a zero Schema; capability checks (group/aggregated fields must be Filterable, SUM/AVG need a numeric attribute) apply only when a schema is present, keeping the permissive-defaults philosophy.

It does not check Sorts: a sort referencing neither a group field nor an alias is dropped by Query.AggregateSorts, not rejected - it is usually an inherited repo default, invisible to the caller.

func (Schema) ValidateQuery

func (s Schema) ValidateQuery(q Query) error

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

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

func (Schema) With

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

With returns a new Schema with the overrides layered on: a matching Name replaces in place, a new Name is appended; the receiver is unchanged. Use it for what tags cannot express - computed attributes, relation labels, tightened operator sets.

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) that drive the read-only timestamp/soft-delete defaults.

type SortDirection

type SortDirection int8

SortDirection is a sort direction.

const (
	// SortDirectionUnspecified falls back to the default direction.
	SortDirectionUnspecified SortDirection = iota
	// SortDirectionAsc sorts ascending.
	SortDirectionAsc
	// SortDirectionDesc sorts descending.
	SortDirectionDesc
)

func (SortDirection) String

func (s SortDirection) String() string

String returns a debug label ("asc"/"desc"/unspecified).

type SortNullsPosition

type SortNullsPosition int8

SortNullsPosition is where NULLs sort (first or last).

const (
	// NullsPositionNotSpecified leaves NULLs position out of the query.
	NullsPositionNotSpecified SortNullsPosition = iota

	// NullsPositionFirst places NULLs first.
	NullsPositionFirst

	// NullsPositionLast places NULLs last.
	NullsPositionLast
)

func (SortNullsPosition) String

func (s SortNullsPosition) String() string

String returns a debug label ("nulls first"/"nulls last"/"").

type SortSpec

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

	NullsPosition SortNullsPosition // First | Last | Unspecified
}

SortSpec is a single sort criterion.

func NewSortAscSpec

func NewSortAscSpec(col *FieldSpec) *SortSpec

NewSortAscSpec sorts by col ascending.

func NewSortDescSpec

func NewSortDescSpec(col *FieldSpec) *SortSpec

NewSortDescSpec sorts by col descending.

func NewSortSpec

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

NewSortSpec is the primary SortSpec constructor.

func (*SortSpec) Clone

func (s *SortSpec) Clone() *SortSpec

Clone returns a deep copy of the sort spec.

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 a debug representation of the sort spec.

type Sorts

type Sorts []*SortSpec

Sorts is a list of *SortSpec.

func (Sorts) Clone

func (sorts Sorts) Clone() Sorts

Clone returns a deep copy of the sorts.

func (Sorts) MergeWith

func (sorts Sorts) MergeWith(other Sorts) Sorts

MergeWith merges other sorts into these.

type Transactor

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

Transactor is the opt-in transaction capability a CRUD may satisfy; many backends (in-memory, YAML files, some NoSQL) do not. Type-assert to detect support, or use InTx for automatic commit/rollback.

type TxCRUD

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

	// Commit commits the transaction; the TxCRUD must not be used afterward.
	Commit() error

	// Rollback aborts the transaction (a no-op if Commit already ran); the
	// TxCRUD must not be used afterward.
	Rollback() error
}

TxCRUD is a CRUD bound to a single transaction, plus Commit/Rollback. It must not be used after either is called.

func BeginTxChain

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

BeginTxChain is the chain-aware counterpart to Transactor.BeginTx: it walks repo's decorator chain to the backend, begins there, and returns a TxCRUD with the same decorator stack re-applied on top. Decorators delegate to it 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 UpsertOption added in v0.0.4

type UpsertOption func(*UpsertSpec)

UpsertOption configures an UpsertSpec.

func OnConflict added in v0.0.4

func OnConflict(cols ...string) UpsertOption

OnConflict sets the conflict-target columns. With no option the target is the primary key.

func UpdateOnConflict added in v0.0.4

func UpdateOnConflict(fields ...*FieldSpec) UpsertOption

UpdateOnConflict restricts which columns are written on conflict. With no option every mutable column is overwritten.

type UpsertSpec added in v0.0.4

type UpsertSpec struct {
	// ConflictColumns is the conflict target — the columns whose collision
	// triggers the update branch. Empty means the primary key.
	ConflictColumns []string
	// UpdateFields are the columns overwritten on conflict. Empty means all
	// mutable columns of the entity (a full "replace").
	UpdateFields Fields
}

UpsertSpec is the resolved configuration of an Upsert: the conflict target and the columns overwritten on conflict. Build it from [UpsertOption]s via NewUpsertSpec; engines and decorators read it to shape the write.

func NewUpsertSpec added in v0.0.4

func NewUpsertSpec(opts ...UpsertOption) UpsertSpec

NewUpsertSpec resolves the given options into a concrete UpsertSpec. Engines implementing Upserter call this to interpret the caller's options.

type Upserter added in v0.0.4

type Upserter[T any, ID comparable] interface {
	// Upsert inserts entity, or updates the colliding row when it conflicts on
	// the conflict target (the primary key by default; see [OnConflict]). It
	// returns the stored row after the write.
	Upsert(ctx context.Context, entity T, opts ...UpsertOption) (T, error)
}

Upserter is the opt-in upsert capability: insert the entity, or update it in place on conflict. The write analogue of Aggregator - reached through UpsertOf (which returns ErrUpsertNotSupported for a backend that lacks it) so decorators always apply, and kept out of core Commander so adding it breaks no existing backend.

type WriteOp

type WriteOp uint8

WriteOp identifies which write a capability check applies to.

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

Directories

Path Synopsis
dialects
bson
Package r3bson is the data-store dialect that translates r3 query types (r3.FilterSpec, r3.SortSpec, and friends) into MongoDB bson.D documents.
Package r3bson is the data-store dialect that translates r3 query types (r3.FilterSpec, r3.SortSpec, and friends) into MongoDB bson.D documents.
canonical
Package canonical is the single source of the string vocabulary the serialization dialects (JSON, YAML, TOML, URL) all map through: operators ("eq", "ne", "gt", ...), sort directions ("asc", "desc"), nulls positions ("first", "last"), and aggregate functions.
Package canonical is the single source of the string vocabulary the serialization dialects (JSON, YAML, TOML, URL) all map through: operators ("eq", "ne", "gt", ...), sort directions ("asc", "desc"), nulls positions ("first", "last"), and aggregate functions.
json
Package r3json converts r3 query types (r3.Filters, r3.Sorts, r3.PaginationSpec, r3.Fields) to and from JSON for REST request/response bodies.
Package r3json converts r3 query types (r3.Filters, r3.Sorts, r3.PaginationSpec, r3.Fields) to and from JSON for REST request/response bodies.
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: r3.Filters to WHERE with positional placeholders, r3.Sorts to ORDER BY, r3.PaginationSpec to LIMIT/OFFSET.
Package r3sql translates r3 query types into SQL clauses: r3.Filters to WHERE with positional placeholders, r3.Sorts to ORDER BY, r3.PaginationSpec to LIMIT/OFFSET.
toml
Package r3toml converts r3 query types (r3.Filters, r3.Sorts, r3.PaginationSpec, r3.Fields) to and from TOML for configuration files, using human-readable names (field/operator/value) like the YAML dialect.
Package r3toml converts r3 query types (r3.Filters, r3.Sorts, r3.PaginationSpec, r3.Fields) to and from TOML for configuration files, using human-readable names (field/operator/value) like the YAML dialect.
url
Package r3url is the serialization dialect between r3 query types and URL query parameters.
Package r3url is the serialization dialect between r3 query types and URL query parameters.
when
Package r3when is the bridge between the years schedule vocabulary and r3 filters.
Package r3when is the bridge between the years schedule vocabulary and r3 filters.
yaml
Package r3yaml converts r3 query types (r3.Filters, r3.Sorts, r3.PaginationSpec, r3.Fields) to and from YAML for configuration files.
Package r3yaml converts r3 query types (r3.Filters, r3.Sorts, r3.PaginationSpec, r3.Fields) to and from YAML for configuration files.
drivers
bun
Package r3bun is an r3.CRUD[T, ID] driver backed by Bun (github.com/uptrace/bun), a SQL-first ORM for PostgreSQL, MySQL, MSSQL, and SQLite.
Package r3bun is an r3.CRUD[T, ID] driver backed by Bun (github.com/uptrace/bun), a SQL-first ORM for PostgreSQL, MySQL, MSSQL, and SQLite.
gopg
Package r3gopg is an r3.CRUD[T, ID] driver backed by go-pg v10 (github.com/go-pg/pg/v10), a PostgreSQL ORM.
Package r3gopg is an r3.CRUD[T, ID] driver backed by go-pg v10 (github.com/go-pg/pg/v10), a PostgreSQL ORM.
gorm
Package r3gorm is an r3.CRUD[T, ID] driver backed by GORM (gorm.io/gorm).
Package r3gorm is an r3.CRUD[T, ID] driver backed by GORM (gorm.io/gorm).
mongo
Package r3mongo is an r3.CRUD[T, ID] driver backed by the official MongoDB Go driver v2 (go.mongodb.org/mongo-driver/v2).
Package r3mongo is an r3.CRUD[T, ID] driver backed by the official MongoDB Go driver v2 (go.mongodb.org/mongo-driver/v2).
mysql
Package r3mysql is a raw-SQL r3.CRUD[T, ID] driver backed by go-sql-driver/mysql (github.com/go-sql-driver/mysql) over database/sql.
Package r3mysql is a raw-SQL r3.CRUD[T, ID] driver backed by go-sql-driver/mysql (github.com/go-sql-driver/mysql) over database/sql.
pgx
Package r3pgx is a raw-SQL r3.CRUD[T, ID] driver backed by jackc/pgx (github.com/jackc/pgx/v5) in database/sql compatibility mode (pgx/v5/stdlib), so it runs through database/sql while keeping pgx's connection and type support.
Package r3pgx is a raw-SQL r3.CRUD[T, ID] driver backed by jackc/pgx (github.com/jackc/pgx/v5) in database/sql compatibility mode (pgx/v5/stdlib), so it runs through database/sql while keeping pgx's connection and type support.
pq
Package r3pq is a raw-SQL r3.CRUD[T, ID] driver backed by lib/pq (github.com/lib/pq) over database/sql.
Package r3pq is a raw-SQL r3.CRUD[T, ID] driver backed by lib/pq (github.com/lib/pq) over database/sql.
sqlite3
Package r3sqlite3 is a raw-SQL r3.CRUD[T, ID] driver backed by mattn/go-sqlite3 (github.com/mattn/go-sqlite3, CGo) over database/sql.
Package r3sqlite3 is a raw-SQL r3.CRUD[T, ID] driver backed by mattn/go-sqlite3 (github.com/mattn/go-sqlite3, CGo) over database/sql.
engine
file
Package enginefile is the filesystem CRUD engine: it reads and writes entities to JSON or YAML files and evaluates every query feature (filters, sorts, pagination, field selection, soft-delete) in memory.
Package enginefile is the filesystem CRUD engine: it reads and writes entities to JSON or YAML files and evaluates every query feature (filters, sorts, pagination, field selection, soft-delete) in memory.
mongo
Package enginemongo is the MongoDB engine for r3: a full r3.CRUD backed by the official Go driver v2, using reflection-based struct metadata and BSON query building.
Package enginemongo is the MongoDB engine for r3: a full r3.CRUD backed by the official Go driver v2, using reflection-based struct metadata and BSON query building.
sql
Package enginesql is r3's SQL engine, serving two roles:
Package enginesql is r3's SQL engine, serving two roles:
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 is a transparent decorator around any r3.CRUD[T, ID] that records every Create, Update, Patch, and Delete as a ChangeRecord.
Package history is a transparent decorator around any r3.CRUD[T, ID] that records every Create, Update, Patch, and Delete as a ChangeRecord.
i18n
Package i18n adds entity-content translations to any r3.CRUD[T, ID].
Package i18n adds entity-content translations to any r3.CRUD[T, ID].
metrics
Package metrics decorates any r3.CRUD[T, ID] with domain-level analytics, recording configurable metrics for every CRUD operation.
Package metrics decorates any r3.CRUD[T, ID] with domain-level analytics, recording configurable metrics for every CRUD operation.
permissions
Package permissions gates every CRUD operation through a caller-supplied authorization policy.
Package permissions gates every CRUD operation through a caller-supplied authorization policy.
softdelete
Package softdelete decorates any r3.CRUD[T, ID] with Restore and HardDelete, transparently satisfying the same interface and passing all standard methods through to the inner repo.
Package softdelete decorates any r3.CRUD[T, ID] with Restore and HardDelete, transparently satisfying the same interface and passing all standard methods through to the inner repo.
transactor
Package transactor decorates any r3.CRUD[T, ID] to surface the driver's transaction support, transparently satisfying the same interface and passing standard methods through to the inner repo.
Package transactor decorates any r3.CRUD[T, ID] to surface the driver's transaction support, transparently satisfying the same interface and passing standard methods through to the inner repo.
validation
Package validation validates entities before mutations (Create, Update, Patch, Upsert, PatchWhere) on any r3.CRUD[T, ID].
Package validation validates entities before mutations (Create, Update, Patch, Upsert, PatchWhere) on any r3.CRUD[T, ID].
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