Fix logging format string bugs and add missing contributor guides
Replace three instances of leftover %T format verbs in logger.ErrorCtx() calls with proper structured logging fields. Add alphabetically-sorted reference documentation for six new contrib/claude/ guides and reorder the existing list. Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
169
contrib/claude/coredata.md
Normal file
169
contrib/claude/coredata.md
Normal file
@@ -0,0 +1,169 @@
|
||||
# Coredata — Data Access Layer
|
||||
|
||||
All raw SQL lives in `pkg/coredata` — never in service, handler, or resolver packages. One file per entity, with companion `*_filter.go` and `*_order_field.go` files when needed.
|
||||
|
||||
## Entity struct pattern
|
||||
|
||||
Every entity uses `gid.GID` for its ID, `db` tags for pgx mapping, and `CreatedAt`/`UpdatedAt` timestamps. The `tenant_id` column exists in the database but is **never** stored on the Go struct — it is injected at query time via `Scoper`.
|
||||
|
||||
```go
|
||||
type (
|
||||
Asset struct {
|
||||
ID gid.GID `db:"id"`
|
||||
SnapshotID *gid.GID `db:"snapshot_id"`
|
||||
Name string `db:"name"`
|
||||
OrganizationID gid.GID `db:"organization_id"`
|
||||
AssetType AssetType `db:"asset_type"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
}
|
||||
|
||||
Assets []*Asset
|
||||
)
|
||||
```
|
||||
|
||||
Use pointer types (`*T`) for nullable database columns.
|
||||
|
||||
## Scoper interface
|
||||
|
||||
`Scoper` provides tenant isolation. Two implementations:
|
||||
|
||||
| Type | Constructor | `SQLFragment()` | `GetTenantID()` | Use case |
|
||||
|------|-------------|-----------------|-----------------|----------|
|
||||
| `Scope` | `NewScope(tenantID)` or `NewScopeFromObjectID(gid)` | `"tenant_id = @tenant_id"` | Returns tenant ID | Multi-tenant queries (default) |
|
||||
| `NoScope` | `NewNoScope()` | `"TRUE"` | **Panics** — never call | Cross-tenant / administrative queries |
|
||||
|
||||
Always inject `tenant_id` at INSERT time using `scope.GetTenantID()`, never from the struct.
|
||||
|
||||
## SQL query composition
|
||||
|
||||
All queries use `fmt.Sprintf` to inject scope/filter/cursor fragments, then `pgx.StrictNamedArgs` for parameters. Merge args with `maps.Copy`.
|
||||
|
||||
```go
|
||||
q := `
|
||||
SELECT id, name, created_at, updated_at
|
||||
FROM assets
|
||||
WHERE
|
||||
%s
|
||||
AND organization_id = @organization_id
|
||||
AND %s
|
||||
AND %s
|
||||
LIMIT %d;
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment(), cursor.SQLFragment(), cursor.Limit())
|
||||
|
||||
args := pgx.StrictNamedArgs{"organization_id": organizationID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
maps.Copy(args, filter.SQLArguments())
|
||||
maps.Copy(args, cursor.SQLArguments())
|
||||
```
|
||||
|
||||
**All SQL must be static** after `fmt.Sprintf()` injection — no conditional string building. Use `CASE WHEN` in SQL for optional filter logic.
|
||||
|
||||
## Standard method signatures
|
||||
|
||||
| Method | Receiver | Returns | Purpose |
|
||||
|--------|----------|---------|---------|
|
||||
| `LoadByID(ctx, conn, scope, id)` | `*Entity` | `error` | Single entity by ID |
|
||||
| `LoadBy*(ctx, conn, scope, key)` | `*Entity` | `error` | Single entity by unique key |
|
||||
| `LoadAllBy*(ctx, conn, scope, parentID, cursor, filter)` | `*Entities` | `error` | Paginated list |
|
||||
| `CountBy*(ctx, conn, scope, parentID, filter)` | `*Entities` | `(int, error)` | Count matching rows |
|
||||
| `Insert(ctx, conn, scope)` | `*Entity` | `error` | Insert, uses `scope.GetTenantID()` |
|
||||
| `Update(ctx, conn, scope)` | `*Entity` | `error` | Update with `RETURNING` |
|
||||
| `Delete(ctx, conn, scope)` | `*Entity` | `error` | Delete entity |
|
||||
| `CursorKey(orderField)` | `*Entity` | `page.CursorKey` | Cursor for pagination |
|
||||
| `AuthorizationAttributes(ctx, conn)` | `*Entity` | `(map[string]string, error)` | Attributes for IAM policy evaluation |
|
||||
|
||||
## Row collection
|
||||
|
||||
```go
|
||||
// Single row
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
asset, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Asset])
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrResourceNotFound
|
||||
}
|
||||
*a = asset
|
||||
|
||||
// Multiple rows
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
assets, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Asset])
|
||||
*a = assets
|
||||
```
|
||||
|
||||
## Sentinel errors
|
||||
|
||||
```go
|
||||
var (
|
||||
ErrResourceNotFound = errors.New("resource not found")
|
||||
ErrResourceAlreadyExists = errors.New("resource already exists")
|
||||
ErrResourceInUse = errors.New("resource is in use")
|
||||
)
|
||||
```
|
||||
|
||||
Map `pgx.ErrNoRows` to `ErrResourceNotFound`. Check unique constraint violations for `ErrResourceAlreadyExists`, foreign key violations for `ErrResourceInUse`.
|
||||
|
||||
## Filters
|
||||
|
||||
Filters implement `SQLFragment() string` and `SQLArguments() pgx.NamedArgs`. Use double pointers for three-state filtering: `nil` = no filter, `*nil` = IS NULL, `*val` = equals.
|
||||
|
||||
```go
|
||||
type AssetFilter struct {
|
||||
snapshotID **gid.GID
|
||||
}
|
||||
|
||||
func NewAssetFilter(snapshotID **gid.GID) *AssetFilter {
|
||||
return &AssetFilter{snapshotID: snapshotID}
|
||||
}
|
||||
|
||||
func (f *AssetFilter) SQLFragment() string {
|
||||
if f.snapshotID == nil {
|
||||
return "TRUE"
|
||||
}
|
||||
if *f.snapshotID == nil {
|
||||
return "snapshot_id IS NULL"
|
||||
}
|
||||
return "snapshot_id = @filter_snapshot_id"
|
||||
}
|
||||
|
||||
func (f *AssetFilter) SQLArguments() pgx.NamedArgs {
|
||||
if f.snapshotID == nil || *f.snapshotID == nil {
|
||||
return pgx.NamedArgs{}
|
||||
}
|
||||
return pgx.NamedArgs{"filter_snapshot_id": **f.snapshotID}
|
||||
}
|
||||
```
|
||||
|
||||
For complex multi-field filters, use `CASE WHEN` in SQL and always declare all argument keys in every code path (use `nil` for inactive ones).
|
||||
|
||||
## Order fields
|
||||
|
||||
String-based enums with `Column()`, `IsValid()`, `String()`, and `MarshalText`/`UnmarshalText`:
|
||||
|
||||
```go
|
||||
type AssetOrderField string
|
||||
|
||||
const (
|
||||
AssetOrderFieldCreatedAt AssetOrderField = "CREATED_AT"
|
||||
AssetOrderFieldName AssetOrderField = "NAME"
|
||||
)
|
||||
```
|
||||
|
||||
Each entity implements `CursorKey(field)` returning `page.NewCursorKey(entity.ID, sortValue)`, with a `panic` on unknown fields.
|
||||
|
||||
## Entity type registry
|
||||
|
||||
Each entity gets a unique `uint16` constant in `entity_type_reg.go`. **Never reuse** removed type numbers — use `_` placeholder. Register new entities in the `NewEntityFromID` switch statement.
|
||||
|
||||
## Migrations
|
||||
|
||||
Files in `pkg/coredata/migrations/` use timestamp naming: `YYYYMMDDTHHMMSSZ.sql` (UTC). One logical change per file. Always create indexes for frequently queried columns.
|
||||
|
||||
## New entity checklist
|
||||
|
||||
1. **Entity file** (`entity.go`) — struct with `db` tags, slice type alias, `LoadByID`, `Insert`, `Update`, `Delete`, `CursorKey`, `AuthorizationAttributes`
|
||||
2. **Filter file** (`entity_filter.go`) — filter struct, `NewEntityFilter`, `SQLFragment`, `SQLArguments`
|
||||
3. **Order field file** (`entity_order_field.go`) — order field type, constants, `Column`, `IsValid`, marshaling
|
||||
4. **Entity type constant** — add to `entity_type_reg.go` and `NewEntityFromID`
|
||||
5. **Migration** — `YYYYMMDDTHHMMSSZ.sql` with CREATE TABLE
|
||||
Reference in New Issue
Block a user