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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,5 @@ go.work.sum

# env file
.env

.settings.local.json
373 changes: 373 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,373 @@
# velum — guidance for AI coding tools

This file provides rules and patterns for AI assistants (Claude, Copilot, Cursor,
and similar tools) generating or modifying code that uses the velum package.

## Mental model

velum sits between raw `database/sql` and a full ORM. It builds SQL from struct
tags and caches the result. You write the struct once; velum generates correct
SELECT / INSERT / UPDATE / DELETE SQL from it, including partial updates and soft
deletes. You never call `rows.Scan` by hand.

Two top-level abstractions:

- `Table[T]` — one per database table. Owns the SQL cache and scan buffers.
- `Dataset[T]` — one per arbitrary SELECT template (joins, CTEs, window functions).

## Initialization rules

`Table` and `Dataset` are **expensive to create and cheap to use**. Always
initialise them once at application startup and share across all requests.

```go
// Correct: package-level or field in a long-lived struct.
var tbl = velum.NewTable[Order]("orders")

// Wrong: inside a handler or service method.
func (s *Service) GetOrder(ctx context.Context, id int) (*Order, error) {
tbl := velum.NewTable[Order]("orders") // allocates and reflects every call
return tbl.GetByPK(ctx, s.db, id)
}
```

The same rule applies to `NewDataset`.

## Struct tag reference (`dbw`)

| Tag value | Meaning |
|---|---|
| *(no tag)* | Column included in full scope (`*`). Name snake_cased from field name. |
| `name=col` | Override column name. Must be the **first** option if present. |
| `gen=serial` | PK generated by `SERIAL` / `DEFAULT`. Omitted from INSERT args. |
| `gen=uuid` | PK generated by `gen_random_uuid()`. |
| `gen=no` | PK value provided by the application. |
| `gen=my_seq` | PK from `nextval('my_seq')`. |
| `scope_name` | Field belongs to the named scope. |
| `scope_a,scope_b` | Field belongs to multiple scopes. |
| `version` | System scope. Auto-incremented (`col=col+1`) on every UPDATE. |
| `insert` | System scope. Included in INSERT only. |
| `update` | System scope. Included in every UPDATE and soft delete. |
| `delete` | System scope. Included in soft-delete operations only. |
| `pk` | Explicitly marks the field as primary key. |
| `-` | Field is ignored entirely. |

`name=` must always appear before any other options:

```go
BirthDate time.Time `dbw:"name=dob,profile"` // correct
BirthDate time.Time `dbw:"profile,name=dob"` // wrong — name= must be first
```

Every field implicitly belongs to `FullScope` ("*") regardless of any named
scopes assigned to it.

## Primary key limitation

`Table[T]` supports **single-column primary keys only**. The PK is detected from
the first field named `id` or the first field tagged `dbw:"pk"`. Only one column
is ever used as the PK regardless of how many fields carry the tag.

Do not generate code that expects composite PK support from `ByPK` methods — it
does not exist. For tables with composite keys, omit the `pk` tag entirely and
use clause-based methods instead:

```go
// Composite PK table — no id field, no dbw:"pk".
type OrderItem struct {
OrderID int
SKU string
Qty int `dbw:"qty"`
}
tbl := velum.NewTable[OrderItem]("order_items")

// All ByPK methods are unavailable. Use clause-based equivalents.
item, err := tbl.Get(ctx, db, velum.FullScope, "WHERE order_id=$1 AND sku=$2", orderID, sku)
rows, err := tbl.Select(ctx, db, velum.FullScope, "WHERE order_id=$1 AND sku=$2", orderID, sku)
_, err = tbl.Update(ctx, db, &item, "qty", "WHERE order_id=$1 AND sku=$2", orderID, sku)
_, err = tbl.Delete(ctx, db, "WHERE order_id=$1 AND sku=$2", orderID, sku)
```

## Scopes

A scope is a named group of fields. Pass a scope to most CRUD methods to
select exactly which columns to read or write.

```go
// FullScope ("*") — all columns including system columns.
tbl.GetByPK(ctx, db, id) // always uses FullScope
tbl.UpdateByPK(ctx, db, &row, velum.FullScope)

// Named scope — only fields tagged with that scope (plus PK when needed).
tbl.UpdateByPK(ctx, db, &row, "price")

// Comma-separated — union of two scopes.
tbl.UpdateByPK(ctx, db, &row, "price,stock")

// Negation — all non-system columns except the named scope.
tbl.UpdateByPK(ctx, db, &row, "!ssn")

// SystemScope — all system columns (version, insert, update, delete).
tbl.UpdateByPK(ctx, db, &row, velum.SystemScope)

// EmptyScope — no data columns; useful for touch-only updates (bumps version).
tbl.UpdateByPK(ctx, db, &row, velum.EmptyScope)
```

## Table methods: usage patterns

### SELECT

```go
// By PK — always uses FullScope.
row, err := tbl.GetByPK(ctx, db, 42)

// By arbitrary clause with a specific scope.
row, err = tbl.Get(ctx, db, "profile", "WHERE email=$1", email)

// Multiple rows.
rows, err := tbl.Select(ctx, db, "price,stock", "WHERE active=$1", true)
rows, err = tbl.SelectAll(ctx, db)

// Existence / count.
ok, err := tbl.ExistByPK(ctx, db, id)
ok, err = tbl.Exist(ctx, db, "WHERE email=$1", email)
n, err := tbl.Count(ctx, db, "WHERE active=$1", true)
```

### INSERT

```go
// Insert and populate system columns in-place (single round-trip).
err := tbl.Insert(ctx, db, &row)

// Insert and return the full row.
inserted, err := tbl.InsertReturning(ctx, db, &row, velum.FullScope, velum.FullScope)

// Insert only the columns in a named scope.
result, err := tbl.InsertScope(ctx, db, &row, "profile")
```

### UPDATE

```go
// Partial update by PK — row_version auto-incremented, updated_at set.
_, err := tbl.UpdateByPK(ctx, db, &row, "price")

// Update and return the result.
updated, err := tbl.UpdateReturningByPK(ctx, db, &row, "price", velum.FullScope)

// Update by arbitrary clause.
_, err = tbl.Update(ctx, db, &row, "stock", "WHERE sku=$1", sku)
```

### DELETE

```go
_, err := tbl.DeleteByPK(ctx, db, id)
_, err = tbl.Delete(ctx, db, "WHERE expired_at < $1", cutoff)

// Soft delete — sets "delete"-scoped fields, bumps version.
_, err = tbl.SoftDeleteByPK(ctx, db, &row)
deleted, err := tbl.SoftDeleteReturningByPK(ctx, db, &row)
```

## Dataset: template placeholders

Placeholders are SQL block comments of the form `/*IDENTIFIER*/` where the
identifier contains only letters, digits, and underscores. Multi-word block
comments (e.g. `/* regular comment */`) are left untouched.

```go
ds := velum.NewDataset[OrderSummary](`
SELECT o.id AS order_id, c.first_name AS customer_name, o.total AS total
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.tenant_id = $1
/*EXTRA_FILTER*/
/*ORDER_BY*/
`)
```

Rules for placeholders:

- Omitted placeholders are replaced with an empty string — no error.
- `$N` parameters inside an injected clause are automatically renumbered to
follow the parameters already present in the base query. Start every clause
at `$1`; velum shifts the numbers.
- `DatasetTailClause` (`"query_tail_clause"`) appends SQL after the template
body without needing a named placeholder in the template.
- Passing an unknown placeholder key to `WithClauses` panics. Use only keys
that appear in the template, or `DatasetTailClause`.

```go
// Correct: clause uses $1, velum shifts it to $2.
ds.WithNamedClause("EXTRA_FILTER", "AND o.paid_at > $1").Select(ctx, db, tenantID, since)

// Wrong: do not hardcode the shifted number yourself.
ds.WithNamedClause("EXTRA_FILTER", "AND o.paid_at > $2").Select(ctx, db, tenantID, since)
```

## Passing the database handle

Pass `db velum.DatabaseWrapper` (or `velum.Transaction`) as a parameter to
every repository method rather than storing it in the struct. This enables
two patterns transparently:

- **Transactions** — pass a `velum.Transaction` from `db.InTx`; every method
participates in the same transaction without changing its signature.
- **Read/write splitting** — pass a replica handle to read methods and a
primary handle to mutating methods at the call site.

```go
// Correct
func (r *OrderRepo) GetOrder(ctx context.Context, db velum.DatabaseWrapper, id int) (*Order, error) {
return r.orders.GetByPK(ctx, db, id)
}

// Wrong — cannot use a transaction, cannot swap to a replica
type OrderRepo struct {
db velum.DatabaseWrapper
orders *velum.Table[Order]
}
```

## Soft delete

Soft delete is a first-class feature. No row is physically removed — velum
issues an UPDATE that sets the "delete"-scoped fields and auto-increments
`row_version`. Any field tagged `dbw:"delete"` participates.

### Struct requirements

The struct must have at least one field tagged `dbw:"delete"`. Typically this
is a nullable timestamp and an optional actor field:

```go
type SystemColumns struct {
RowVersion int64 `dbw:"version"`
CreatedAt time.Time `dbw:"insert"`
UpdatedAt *time.Time `dbw:"update"`
DeletedAt *time.Time `dbw:"delete"`
DeletedBy *int `dbw:"delete"`
}
```

If the struct has no `dbw:"delete"` fields, `SoftDeleteByPK` and
`SoftDeleteReturningByPK` will still compile but produce an UPDATE with no
meaningful columns changed.

### Caller responsibility: populate delete fields before calling

velum reads the delete-scope values **from the row you pass in**. Set them
before calling `SoftDeleteByPK`:

```go
now := time.Now()
actorID := 99

row.DeletedAt = &now
row.DeletedBy = &actorID

// UPDATE customers
// SET deleted_at=$2, deleted_by=$3, row_version=row_version+1, updated_at=$4
// WHERE id=$1
_, err := tbl.SoftDeleteByPK(ctx, db, row)
```

`UpdatedAt` (the `dbw:"update"` field) and `RowVersion` (the `dbw:"version"`
field) are handled automatically — you do not need to set them.

### Methods

```go
// Soft delete — no row returned.
_, err := tbl.SoftDeleteByPK(ctx, db, row)

// Soft delete — returns system columns of the updated row (version, timestamps).
updated, err := tbl.SoftDeleteReturningByPK(ctx, db, row)
```

`SoftDeleteReturningByPK` returns a `*T` whose system-scope fields
(`version`, `insert`, `update`, `delete`) are populated from the database
RETURNING clause. Use it when you need the final `row_version` or the exact
server-side timestamp.

### SELECT after soft delete

velum does **not** automatically filter out soft-deleted rows in any SELECT
method. You must add the condition yourself:

```go
// Correct: exclude soft-deleted rows explicitly.
rows, err := tbl.Select(ctx, db, velum.FullScope, "WHERE deleted_at IS NULL")

// Wrong: returns all rows including soft-deleted ones.
rows, err = tbl.SelectAll(ctx, db)
```

This is intentional — it keeps queries predictable and lets you query the
deleted rows when you need an audit trail.

## Transaction pattern

```go
err := db.InTx(ctx, func(tx velum.Transaction) error {
inserted, err := r.orders.InsertReturning(ctx, tx, &order, velum.FullScope, velum.FullScope)
if err != nil {
return err
}
_, err = r.customers.UpdateByPK(ctx, tx, &customer, velum.EmptyScope)
return err
})
```

`velum.Transaction` satisfies the same executor interfaces as
`velum.DatabaseWrapper`, so every Table and Dataset method works unchanged
inside a transaction.

## Critical: always use parameterized clauses

`Table` caches SQL strings keyed by the exact `(scope, clause)` pair. The
cache grows by one entry per unique clause string and is never evicted.

```go
// Correct — one cache entry for the lifetime of the process.
tbl.Select(ctx, db, "*", "WHERE id=$1", id)

// Wrong — new cache entry for every distinct value of id; memory leak.
tbl.Select(ctx, db, "*", fmt.Sprintf("WHERE id = %d", id))
```

The same rule applies to `Dataset.WithClauses`: build `ClauseSet` with
parameterized SQL and pass runtime values as `Select`/`Get` arguments.

## Testing without a database

Implement the three small executor interfaces with stubs to test repository
methods without spinning up a database container:

```go
type fakeRows struct{ n int }
func (r *fakeRows) Next() bool { r.n--; return r.n >= 0 }
func (r *fakeRows) Close() error { return nil }
func (r *fakeRows) Err() error { return nil }
func (r *fakeRows) Scan(_ ...any) error { return nil }

type fakeDB struct{}
func (db *fakeDB) ExecContext(_ context.Context, _ string, _ ...any) (velum.Result, error) {
return fakeResult{}, nil
}
func (db *fakeDB) QueryRowContext(_ context.Context, _ string, _ ...any) velum.Row {
return &fakeRow{}
}
func (db *fakeDB) QueryContext(_ context.Context, _ string, _ ...any) (velum.Rows, error) {
return &fakeRows{n: 3}, nil
}
func (db *fakeDB) IsNotFound(err error) bool { return false }
func (db *fakeDB) Begin(ctx context.Context) (velum.Transaction, error) { ... }
func (db *fakeDB) InTx(ctx context.Context, fn func(velum.Transaction) error) error { ... }
```

Pass `&fakeDB{}` wherever a `velum.DatabaseWrapper` is required. This covers
all Table and Dataset method paths without testcontainers.
Loading