Files
probo/contrib/claude/coredata.md
Bryan Frimin 7895dd32b0 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>
2026-03-18 15:09:31 +01:00

6.1 KiB

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.

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.

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

// 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

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.

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:

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