Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
name: Docs

on:
push:
branches: [main]
paths:
- 'docs/**'
- 'mkdocs.yml'
- '.github/workflows/docs.yml'
workflow_dispatch:

permissions:
contents: read
pages: write
id-token: write

concurrency:
group: pages
cancel-in-progress: false

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: pip
- run: pip install "mkdocs-material>=9.5" pymdown-extensions
- run: mkdocs build --strict --site-dir site
- uses: actions/upload-pages-artifact@v3
with:
path: site

deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v4
412 changes: 88 additions & 324 deletions README.md

Large diffs are not rendered by default.

70 changes: 70 additions & 0 deletions docs/architecture/adapters-internals.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Adapter internals

An adapter is a thin wrapper that turns `executor.DBAdapter` calls into driver-specific calls. All bundled adapters follow the same layout; differences come down to placeholder rewriting and transaction semantics.

## Anatomy of a wrapper

```
executor/adapters/<driver>/
pool.go — NewPoolAdapter / NewAdapter + ExecContext / QueryContext / BeginTx
tx.go — txWrap implementing executor.Tx
rows.go — rowsWrap adapting driver rows to types.Rows
result.go — resultWrap adapting driver result to types.Result
```

The rest is boilerplate.

## Placeholder rewriting

gerpo's SQL uses `?`. Each adapter rewrites placeholders exactly once, right before handing the query to the driver:

```go
sql, err := placeholder.Dollar.ReplacePlaceholders(query)
```

`executor/adapters/placeholder/` provides two formats:

- `placeholder.Question` — no-op (input already `?`).
- `placeholder.Dollar` — scan-and-emit rewriter that turns `?` into `$1, $2, …`.

`databasesql.NewAdapter` defaults to `Question`. `pgx4` / `pgx5` always use `Dollar`.

## Rows wrapper

pgx returns `pgx.Rows`, `database/sql` returns `*sql.Rows`. Both shapes are close enough to the `types.Rows` interface, but they differ in `Scan` behavior (nullable types, text decoding). `rowsWrap` exists so gerpo can pretend both are identical.

## Result wrapper

`types.Result` exposes only `RowsAffected() (int64, error)`. Both pgx and `database/sql` return something richer, but gerpo only needs this one metric.

## Transaction wrapper

`txWrap` stores:

```go
type txWrap struct {
commited bool
rollbackUnlessCommittedNeeded bool
tx <driver>.Tx // or *sql.Tx
}
```

- `Commit()` — calls driver commit, then sets `commited = true` on **success**.
- `Rollback()` — sets `rollbackUnlessCommittedNeeded = false`, then calls driver rollback.
- `RollbackUnlessCommitted()` — if `!commited && rollbackUnlessCommittedNeeded`, delegates to `Rollback()`; otherwise no-op. Designed to be safe as a `defer`.

All three methods use pointer receivers so the state mutations actually stick.

!!! warning "Historical bug"
pgx v4 and v5 adapters originally used value receivers and also forgot to set `commited`. `RollbackUnlessCommitted()` after `Commit()` returned `tx is closed`. The integration test `TestTx_RollbackUnlessCommitted_AfterCommit` catches this; fixed in the `test: cover hooks, soft delete, …` commit.

## Writing your own

Walk through `executor/adapters/pgx5/` as a template. You will need:

- decide whether to rewrite placeholders (most non-PG drivers keep `?`; PG-derived drivers want `$N`);
- wrap the driver's `Rows` type in something satisfying `types.Rows`;
- wrap the driver's transaction type in `txWrap` following the rules above;
- return a `types.DBAdapter` implementation.

A good smoke test is `TestSmoke` in `tests/integration/` — `forEachAdapter` will pick up your new bundle once you add it to `allAdapters()`.
71 changes: 71 additions & 0 deletions docs/architecture/caching.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Caching internals

The cache is a plug-in. `executor/cache/types.Storage` is the interface; `executor/cache/ctx` is the bundled context-scoped implementation.

## The Storage interface

```go
type Storage interface {
Get(ctx context.Context, stmt string, args ...any) (any, error)
Set(ctx context.Context, value any, stmt string, args ...any)
Clean(ctx context.Context)
}
```

- **Get** returns the cached value or `cache.ErrNotFound`.
- **Set** records a value.
- **Clean** wipes the cache (the executor calls it after INSERT/UPDATE/DELETE so stale reads don't linger).

The executor is the only caller. `executor/cache.go` wraps `Storage` in three helpers (`get[T]`, `set`, `clean`) that accept a nil storage and no-op — so switching the cache off is as simple as not passing `executor.WithCacheStorage`.

## CtxCache

Source: `executor/cache/ctx/source.go`, `executor/cache/ctx/storage.go`.

The idea: the cache payload lives **inside the request context**. A repo-level `CtxCache` object has a stable UUID-shaped `key` that lets it partition the context-scoped storage by repo.

```
┌────────────────────────────────────────────┐
│ context.Context │
│ │
│ ctxCacheKey → *cacheStorage { │
│ mtx: sync.Mutex │
│ c: map[string]map[string]any │
│ │
│ ↑ ↑ │
│ repo A's key repo B's key │
│ │
│ } │
└────────────────────────────────────────────┘
```

- `ctx.NewCtxCache(ctx)` installs a `cacheStorage` into the context.
- Repo A's reads/writes go to `storage.c["repo-A-uuid"]`.
- On `Clean` only the repo's bucket is cleared, not the whole tree.

`cacheStorage.Get` looks up `modelKey → key → value`. `modelKey` is the repo UUID; `key` is `sql + args`.

## Cache key

`CtxCache.Get/Set` build a string key from the SQL statement and its arguments. Until recently this went through `fmt.Sprintf("%s%v", sql, args)`, which allocated a slice of strings plus the formatted copy. Now keys go through a `strings.Builder` with a type switch covering common argument types (`string`, integers, `uuid.UUID`, `[]byte`, `bool`, `nil`) — each of those writes directly into the builder. Uncommon types fall back to `fmt.Fprint`.

Result: 3–5 fewer allocations per cache operation, no change in key identity.

## Invalidation

The executor calls `clean(ctx, cacheSource)` after successful `InsertOne`, `Update`, `Delete`. Read operations don't invalidate. Thus:

- a repo-managed mutation always clears the repo's cache bucket for the current request context;
- changes made to the database through a different path (raw SQL, another repo, an external service) are invisible to the cache until someone writes through the repo in that context.

## Thread safety

`cacheStorage` is protected by a `sync.Mutex`. The lock is fine-grained per write and per read — contention is low in practice because most requests touch distinct keys.

## Building your own Storage

Anything satisfying `cache.Storage` works. Common candidates:

- Redis — for cross-instance sharing within a trace/tenant.
- `sync.Map` with TTL — for a process-wide cache, but mind invalidation semantics.
- `cache.NewModelBundle` — already bundled; combines several storages into one (e.g. ctx + redis).
71 changes: 71 additions & 0 deletions docs/architecture/contributing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Contributing

## Development environment

- Go 1.21+.
- Docker for integration tests.
- `mkdocs-material` if you want to preview the docs locally (`pip install mkdocs-material && mkdocs serve`).

## Check-in loop

```bash
# Unit tests + race detector
go test -race ./...

# Integration tests (PostgreSQL in Docker)
docker compose -f tests/integration/docker-compose.yml up -d
GERPO_INTEGRATION_DB_URL="postgres://gerpo:gerpo@localhost:5433/gerpo?sslmode=disable" \
go test -tags=integration ./tests/integration/...

# Direct-vs-gerpo allocation benchmarks
go test -bench='^Benchmark(GetFirst|GetList|Count|Insert|Update|Delete)_(Direct|Gerpo)$' \
-benchmem -run=^$ -count=5 ./tests/

# Formatted summary
GERPO_BENCH_REPORT=1 go test -run=TestCompareDirectVsGerpo -v ./tests/
```

## Code style

- Package names are lowercase and short.
- Interfaces end in `-er` / `-or` when they describe behaviour (`DBAdapter`, `WhereTarget`, `Operation`).
- Every public API ships with godoc — keep the tone concise.
- Generic parameter for the model is `[TModel any]`, consistently.

## Tests

- Unit tests live beside the code (`*_test.go`). Use `go-sqlmock` for `database/sql` paths.
- Integration tests go under `tests/integration/` with the `//go:build integration` tag. They target every adapter in a single run through `forEachAdapter`.
- Benchmarks live in `tests/` (no build tag — `go test -bench=`).

## Commit style

Follow the existing log: lowercase type prefix, imperative subject, optional body with bullet points:

```
perf: replace closures in query/linq builders with structured ops

Where/Order/Exclude/Group/Join no longer store per-condition closures …
```

Common types used in the repo: `feat:`, `fix:`, `perf:`, `test:`, `docs:`, `ci:`, `build:`, `refactor:`, `src:`.

## Opening a PR

A PR to `main` runs three jobs:

- `unit` — build, race detector, full `go test`.
- `integration` — `//go:build integration` against a PG service container.
- `bench-diff` — runs mock benchmarks on head and on base, posts a [benchstat](https://pkg.go.dev/golang.org/x/perf/cmd/benchstat) summary as a PR comment.

`bench-diff` is `allow_failure: true` — a perf regression shows up in the comment but doesn't block merging on its own. Look at the comment before asking for review.

## Updating the docs

- English only.
- Prefer runnable snippets. If the snippet would need imports to compile, pick an example from `examples/` or from the integration tests and copy it verbatim.
- Don't duplicate godoc — link to `pkg.go.dev` instead.

## Releasing

Tag `vX.Y.Z`, push the tag. Prior to 1.0.0 the API is not guaranteed to be stable — call out anything breaking in the release notes.
51 changes: 51 additions & 0 deletions docs/architecture/field-mapping.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Field mapping

gerpo's pointer-based column binding is powered by [`github.com/insei/fmap/v3`](https://github.com/insei/fmap), a small library that learns the layout of a struct once and exposes O(1) access to each field through its pointer address.

## Why not reflect.Type?

Classic reflection identifies a field by name or index. Both are brittle: renames silently shift indices, typos escape the compiler. gerpo wants code like

```go
c.Field(&m.Email).AsColumn()
```

where `&m.Email` is the reference. That needs a trick: convert a pointer-to-field into a stable field identifier.

## How fmap does it

1. On `gerpo.NewBuilder[T]()`, gerpo creates a zero `T` on the heap once.
2. `fmap` walks the struct using reflection and records each field's `unsafe.Offsetof`.
3. For every `c.Field(&m.X)` the builder takes the pointer `&m.X`, subtracts the base address of the zero-struct, and looks up the field by offset. O(1).
4. At query time, when the repo has a real `*T`, it reads/writes the field by adding the offset back — `unsafe.Pointer` arithmetic without `reflect.Value`.

Pointer-to-field resolution happens once per repo build, not per request. Per-request, gerpo only does pointer arithmetic and interface boxing — no `reflect.Value` on hot paths.

## What fmap returns

`fmap.Field` implements enough to:

- fetch the Go type (`GetType`, `GetDereferencedType`) — used when generating operators for a given column;
- read the value (`Get(model)`) — used by `GetModelValues` when building INSERT/UPDATE;
- write the value (`Set(model, val)`) — used by `SoftDeletionBuilder.SetValueFn`;
- obtain a pointer (`GetPtr(model)`) — used by `GetModelPointers` for `rows.Scan`.

`column.Column` wraps an `fmap.Field` plus the operator table, table name, alias, and allowed SQL actions.

## Allocation behaviour

fmap allocates once per struct (not per field, not per query). Its `Set` uses a compiled set of pointer-tricks per Go kind — no `reflect.Call`, no boxing on the hot path.

gerpo's own overhead on top is:

- one `[]any` for `GetModelPointers` (required by `rows.Scan`);
- one `[]any` for `GetModelValues` on INSERT/UPDATE (required by the driver to marshal parameters);
- one allocation per closure stored in the WHERE/ORDER plan.

Those slices are a known allocation source — see the backlog of allocation ideas in the repository's memory.

## Limitations

- Nested anonymous structs are supported; unexported fields are not.
- Interface-typed fields can be columns, but conversion to SQL values depends on the driver. Prefer concrete types.
- `fmap` uses `unsafe`, so the usual caveats apply: struct layout must match what `fmap` saw at init, i.e. no hot-swapping types or binary-incompatible upgrades.
63 changes: 63 additions & 0 deletions docs/architecture/ideology.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Ideology

The five rules gerpo commits to (from the README):

1. **If SQL exists in your project, it lives only in the repository configuration.**
2. **Every column is bound to a struct field through a pointer.** Not by name, not by tag.
3. **Entities carry no database markers.** No tags, no special interfaces.
4. **We do not implement relations between entities.** No `hasMany`, no `belongsTo` — that's the business layer's job.
5. **We do not modify the database schema.** No migrations, no `CREATE`/`ALTER` — gerpo only reads and writes data.

These rules aren't stylistic preferences — they are constraints that define the shape of the whole library. Any proposal that breaks one of them is almost certainly heading toward an ORM, and gerpo is deliberately not an ORM.

## Why "not an ORM"?

- **ORM models** try to hide SQL and table structure behind an object model. But SQL leaks: through N+1, through implicit migrations, through queries you can't express. Sooner or later, the team has to understand both layers anyway.
- **gerpo's repository model** is honest: yes, this is a SQL database, yes, there are tables. You work with them directly — just with the convenience of a type-safe configuration.

## The cost and the payoff of each rule

### 1. SQL only in the config

**Cost:** you have to describe columns and persistent conditions upfront; you cannot slip a JOIN into a handler "just for this call".

**Payoff:** exactly one place where the schema is read from. SQL changes are always visible in the PR.

### 2. Bindings via pointers

**Cost:** one struct field = one pointer in the config. No `c.Column("name")`.

**Payoff:** renames are a plain refactor, typos are caught by the compiler. You can't misspell a column name in a string.

### 3. No database markers on entities

**Cost:** a plain Go struct with no schema information — you keep it in a separate config.

**Payoff:** a domain entity stays a domain entity. It doesn't drag `json:"foo" db:"bar" validate:"…"` along with it — those concerns live where they should.

### 4. No relations

**Cost:** if there's a 1-N relationship between `User` and `Post`, you write the matching methods yourself (`FindPostsByUser`) — gerpo offers no magic navigation.

**Payoff:** no lazy-load tornadoes, a predictable number of queries.

### 5. No migrations

**Cost:** the database schema is managed by a separate tool (`golang-migrate`, `goose`, `atlas`, …).

**Payoff:** one layer, one responsibility. gerpo happily survives any schema-versioning scheme.

## Side-by-side summary

| | GORM / ent | gerpo |
|---|---|---|
| SQL hidden | ✔ | ✘ (visible in the config) |
| Migrations | ✔ | ✘ |
| Relations | ✔ | ✘ |
| Struct tags | ✔ | ✘ |
| Field pointers | ✘ | ✔ |
| CRUD + WHERE DSL | ✔ | ✔ |
| Multiple drivers | ✔ | ✔ |
| Context-aware cache | partial | ✔ |

gerpo is intentionally **smaller**, and that is its value proposition.
Loading
Loading