data

package
v0.13.0 Latest Latest
Warning

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

Go to latest
Published: Aug 7, 2026 License: MIT Imports: 10 Imported by: 0

Documentation

Overview

Package data defines the data access contract.

There is no ORM. Queries are plain parameterized SQL -- generated by sqlc from .sql files once phase 2 lands -- which makes SQL injection impossible and the query plan predictable. What this package adds on top is:

  1. a security.Grant required by every operation (the mandatory path);
  2. tenant scoping taken from the Grant, never from a parameter;
  3. automatic instrumentation into the Collector.

Index

Constants

View Source
const KeyText = "VARCHAR(255)"

KeyText is how a text column that takes part in a key is declared.

TEXT is the portable spelling for text, and it is the wrong one for anything indexed: MySQL stores TEXT off-page and refuses it in a key without a prefix length, so `id TEXT PRIMARY KEY` fails with "BLOB/TEXT column used in key specification without a key length". That is the first statement `aru migrate` runs, which means MySQL never got past creating the tracking table -- and every project table repeated the same mistake. Found by audit.

VARCHAR(255) is accepted by all three: PostgreSQL treats it as varchar, SQLite gives it TEXT affinity because the name contains CHAR, and MySQL indexes it. 255 rather than something tighter so there is one width to remember, and because two of them in a composite index still fit under InnoDB's key limit.

The rule: TEXT for free-form content nobody indexes, KeyText for an id, a tenant, or anything a UNIQUE or an index names.

View Source
const MigrationsTable = "arandu_migrations"

MigrationsTable is where applied migration ids are recorded.

Variables

This section is empty.

Functions

func Day added in v0.10.0

func Day(t time.Time) time.Time

Day truncates a time to midnight UTC, which is what a date column means.

It exists because DATE is the one type in the portable subset that the three engines do not agree about. PostgreSQL drops the time part on write, so the value read back differs from the one written. SQLite gives DATE numeric affinity and stores whatever the driver sends, time and zone included -- so the same code, on the same day, returns different values depending on the engine, and a comparison between two dates is true on one and false on the other. Found by audit.

Normalizing on the way in makes the engines agree: what SQLite stores is what PostgreSQL would have stored anyway. `aru make:module` emits it for every field declared as a date.

func InTransaction added in v0.3.0

func InTransaction(ctx context.Context, db *DB) bool

InTransaction reports whether the context is inside a transaction on db.

The outbox uses it to refuse to store an event outside a transaction, which is the whole guarantee: an event written next to a row that rolled back is worse than no event at all.

It takes the handle because "in a transaction" is only meaningful about one database. An outbox on the analytics handle is not protected by a transaction open on the primary.

func Migrate

func Migrate(ctx context.Context, db *DB, migrations []Migration) ([]string, error)

Migrate applies the pending migrations, in the given order, and returns the ids it applied.

Everything applied by one call shares a batch number, which is what makes Rollback undo a deploy rather than a single migration -- the same model Laravel uses, and the reason its rollback is useful in practice.

Each migration runs inside its own transaction together with the insert into the tracking table, so a failure halfway cannot leave the schema ahead of the record. It stops at the first failure: applying later migrations over a broken schema turns one clear error into an unrecoverable database.

func NewID added in v0.3.0

func NewID() (string, error)

NewID returns a version 4 UUID as text.

Ids are generated by the application, not by the database: gen_random_uuid, UUID() and randomblob are three different spellings of the same idea, and depending on any of them would tie the schema to one engine.

func Rollback added in v0.2.0

func Rollback(ctx context.Context, db *DB, migrations []Migration) ([]string, error)

Rollback undoes the last batch and returns the ids it reverted, most recent first.

A migration with an empty Down is refused rather than skipped: silently leaving part of a batch in place is how a rollback produces a schema that matches neither version.

func Tenant

func Tenant(g security.Grant) string

Tenant returns the tenant from the Grant. Every multi-tenant statement must take this value, never a tenant that came in with the request.

func Transaction added in v0.3.0

func Transaction(ctx context.Context, db *DB, fn func(context.Context) error) error

Transaction runs fn inside a database transaction.

Every statement issued through the same *DB while fn runs joins it, because the transaction travels on the context. Returning an error rolls back; returning nil commits. A panic rolls back and keeps panicking -- swallowing it would leave the caller believing the write happened.

A Transaction inside a Transaction joins the outer one rather than opening a second. There are no savepoints: partial rollback is a second failure mode for the same operation, and the shape this framework wants is one write, one outcome.

Types

type AppliedMigration added in v0.2.0

type AppliedMigration struct {
	ID        string
	Batch     int
	AppliedAt time.Time
}

AppliedMigration is one row of the tracking table.

func AppliedMigrations

func AppliedMigrations(ctx context.Context, db *DB) ([]AppliedMigration, error)

AppliedMigrations returns the tracking table, ordered by batch and id.

func Status added in v0.2.0

func Status(ctx context.Context, db *DB, migrations []Migration) ([]AppliedMigration, error)

Status returns every declared migration with the batch it was applied in, or zero when it is still pending. It is what `aru migrate:status` prints.

type DB

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

DB wraps *sql.DB to instrument the Collector and to rebind placeholders for the connection's dialect. Repositories use this type rather than *sql.DB, which is what makes every query show up on the debug page with the file and line that issued it.

It holds no driver import: the driver is chosen by the application, so the core keeps its two dependencies.

func Wrap

func Wrap(db *sql.DB, dialect Dialect) *DB

Wrap returns an instrumented handle over an open *sql.DB.

The dialect is what queries written with "?" are rebound to. An empty dialect means SQLite, which is the development default.

func (*DB) BeginTx

func (d *DB) BeginTx(ctx context.Context, opts *sql.TxOptions) (*sql.Tx, error)

BeginTx starts a raw transaction on the underlying handle.

Prefer data.Transaction: statements run through this one are invisible to the Collector, and the outbox refuses to store events on it, because nothing connects it to the context that repositories read.

func (*DB) Dialect added in v0.2.0

func (d *DB) Dialect() Dialect

Dialect reports the flavour this handle speaks. Repositories use it only when a statement genuinely cannot be written portably -- which should be rare, and is a smell worth explaining in a comment when it happens.

func (*DB) ExecContext

func (d *DB) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)

ExecContext runs a statement and records it, with the affected row count.

Inside data.Transaction it runs on the open transaction.

func (*DB) PingContext

func (d *DB) PingContext(ctx context.Context) error

PingContext verifies the connection. It feeds module health checks.

func (*DB) QueryContext

func (d *DB) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)

QueryContext runs a query and records it.

Inside data.Transaction it runs on the open transaction. That is what lets a repository written once work in both places, and what puts the outbox write in the same transaction as the row it describes.

func (*DB) QueryRowContext

func (d *DB) QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row

QueryRowContext runs a single-row query and records it.

The duration measured here covers issuing the query only: database/sql defers the actual work to Row.Scan, so a slow row shows up on the timeline as scan time rather than query time.

func (*DB) Unwrap

func (d *DB) Unwrap() *sql.DB

Unwrap returns the underlying handle, for the rare case that needs a driver specific feature. Prefer the wrapper: what goes through Unwrap does not show up on the debug page.

type Dialect added in v0.2.0

type Dialect string

Dialect is the SQL flavour of a connection.

The names match Laravel's DB_CONNECTION values, because the .env of an Arandu project is meant to be readable by someone arriving from there.

const (
	// DialectSQLite is the default for local development: a file, no server,
	// nothing to install.
	DialectSQLite Dialect = "sqlite"
	// DialectPostgres is the production target.
	DialectPostgres Dialect = "pgsql"
	// DialectMySQL is accepted by the connection layer. Repositories are not
	// portable to it yet: MySQL has no RETURNING, so every insert needs a second
	// statement. See docs/adr/0009.
	DialectMySQL Dialect = "mysql"
)

Supported dialects.

func ParseDialect added in v0.2.0

func ParseDialect(v string) (Dialect, error)

ParseDialect validates a DB_CONNECTION value.

func (Dialect) Driver added in v0.2.0

func (d Dialect) Driver() string

Driver is the database/sql driver name a dialect expects to be registered under. The application imports the driver; the framework only names it, which is what keeps the core free of database dependencies.

func (Dialect) Rebind added in v0.2.0

func (d Dialect) Rebind(query string) string

Rebind translates the portable "?" placeholder into what the dialect expects.

Every query in this framework is written with "?", the form SQLite and MySQL use, and Postgres gets "$1, $2, ..." here. That is the entire portability layer: there is no query builder, and the SQL you read in a repository is the SQL that runs. Anything beyond placeholders -- a type, a function -- is the repository's job to keep portable.

Placeholders inside string literals are left alone, because '?' is an ordinary character in a LIKE pattern or in seeded data.

type Migration

type Migration struct {
	ID   string
	Up   string
	Down string
}

Migration is a versioned, immutable-once-published schema change.

The id carries its own order -- "2026_07_29_000001_create_users_table" -- for the same reason Laravel names its files that way: a migration that sorts differently on two machines applies in a different order on two machines.

func Pending

func Pending(ctx context.Context, db *DB, migrations []Migration) ([]Migration, error)

Pending returns the migrations that have not been applied yet, in order.

type Query

type Query struct {
	Limit  int
	Cursor string
	Sort   string
}

Query is pagination and ordering with an allowlist. The sort field is NEVER interpolated directly: the repository validates it against a permitted set, or ordering becomes injection through another door.

There is no Filter here, and that is a decision rather than an omission.

The field existed, exported, with no producer and no consumer in any of the ten repositories: List(ctx, g, Query{Filter: ...}) returned the whole list, with no error and no warning. A field that silently does nothing is worse than one that does not exist -- the reader assumes it filtered, and in an application where rows belong to tenants that assumption is how a leak starts.

Filling it in would mean a generic predicate language over columns, which is a query builder, which is the second way to reach data that RULE 9 rules out (docs/09 lists "ORM, query builder fluente, segunda camada" as what does not enter). A module that needs to read by something other than the id declares the method it needs on its own repository, with the SQL written out and the values in placeholders.

type Repository

type Repository[T any, ID comparable] interface {
	Find(ctx context.Context, g security.Grant, id ID) (T, error)
	List(ctx context.Context, g security.Grant, q Query) ([]T, error)
	Create(ctx context.Context, g security.Grant, entity T) (T, error)
	Update(ctx context.Context, g security.Grant, entity T) (T, error)
	Delete(ctx context.Context, g security.Grant, id ID) error
}

Repository is the contract every module repository implements.

Look at the signature: security.Grant is mandatory and comes before the id. Because a Grant cannot be constructed outside the security package, there is no path from a handler to the database that skips a Policy.

type Tx added in v0.3.0

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

Tx is an instrumented transaction.

Statements run through it are recorded on the Collector exactly like the ones outside, which matters more than it sounds: a query that only misbehaves inside a transaction is the one nobody can see on the debug page.

Jump to

Keyboard shortcuts

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