Skip to content
latest
Data and Services / latest

SQL Queries

SQL is explicit and colocated. Every domain owns an <entity>.sql file next to its Go file, and narsilc compiles those files into typed Go in domains/internal/queries. There is no central query directory and no separate sqlc configuration file.

Write the query beside the domain

Queries are annotated SQL. The annotation fixes the generated method shape and the @order comment names a column the generated builder can sort by.

 1-- name: GetUser :one
 2SELECT *
 3FROM users
 4WHERE id = $1
 5LIMIT 1;
 6
 7-- name: ListUsers :many
 8-- @order id
 9SELECT *
10FROM users;
11
12-- name: CreateUser :one
13INSERT INTO users (
14    id, created_at, updated_at, email, email_validated_at, password, is_admin, session_version
15) VALUES (
16    $1, $2, $3, $4, $5, $6, $7, $8
17)
18RETURNING *;

Narsilc understands :one, :many, :exec, and :execrows. Keep the SQL with the receiver that calls it: domains/user.sql for domains.Users, domains/token.sql for domains.Tokens.

Compile with andurel sync queries

1andurel sync queries --json

Narsilc globs domains/**/*.sql and writes domains/internal/queries. The command is a no-op when there are no annotated query files. andurel run and andurel build also regenerate when query files exist, and andurel doctor reports drift.

Only domains/** may import the generated package. andurel verify reports queries-import if a handler, service, or test imports it directly.

Call generated queries from a receiver

Generated clients accept storage.Connection directly, and every query is generic over the entity, so the domain maps rows itself:

 1func NewUsers(db storage.Connection) Users {
 2    return Users{transactor: storage.NewTransactor(db), queries: queries.New(db)}
 3}
 4
 5func (u Users) Find(ctx context.Context, id uuid.UUID) (User, error) {
 6    return storage.Row(u.queries.GetUser[User](ctx, id))
 7}
 8
 9func (u Users) Create(ctx context.Context, pepper string, data CreateUserData) (User, error) {
10    // ...build, normalise and validate the entity; hash the password...
11    created, err := u.queries.CreateUser[User](ctx, queries.CreateUserParams{
12        ID:               entity.ID,
13        CreatedAt:        entity.CreatedAt,
14        UpdatedAt:        entity.UpdatedAt,
15        Email:            entity.Email,
16        EmailValidatedAt: entity.EmailValidatedAt,
17        Password:         entity.Password,
18        IsAdmin:          entity.IsAdmin,
19        SessionVersion:   entity.SessionVersion,
20    })
21
22    return storage.Row(created, storage.Conflict(err, "users_email_key"))
23}

Map expected failures with storage.Row and storage.Conflict, and return the entity, never queries.UserRow.

Paginate with the generated builder

 1req := storage.Page(page, pageSize)
 2
 3totalCount, err := u.queries.CountUsers(ctx)
 4if err != nil {
 5    return storage.Paginated[User]{}, err
 6}
 7
 8entities, err := u.queries.ListUsers[User](ctx).
 9    Limit(req.Limit).
10    Offset(req.Offset).
11    All()
12if err != nil {
13    return storage.Paginated[User]{}, err
14}
15
16return storage.NewPaginated(entities, int64(totalCount), req), nil

storage.Page clamps the page, the page size, and the int32 offset, so an oversized or negative page cannot overflow the query.

Share a transaction

Bind the receiver, not the generated client:

 1err := storage.AtomicOps(ctx, i.transactor, func(ctx context.Context, tx storage.Tx) error {
 2    users, tokens := i.users.InTx(tx), i.tokens.InTx(tx)
 3
 4    token, err := tokens.FindByUserAndScope(ctx, userID, domains.UserEmailVerification)
 5    if err != nil {
 6        return err
 7    }
 8
 9    if _, err := tokens.IncrementAttempts(ctx, token.ID); err != nil {
10        return err
11    }
12
13    validatedAt := pgtype.Timestamptz{Time: time.Now(), Valid: true}
14    _, err = users.Update(ctx, domains.UpdateUserData{
15        ID:               userID,
16        EmailValidatedAt: &validatedAt,
17    })
18
19    return err
20})

InTx rebinds both the generated queries and the receiver's transactor to the open transaction. A service never unwraps a transaction or calls generated queries itself.

Keep the mapping honest

The Go entity is the source of truth for columns. Every column an automatic generated query returns must map to an exported andurel tag; otherwise andurel verify reports entity-columns. If a query narrows the columns, expose only fields that exist on the entity.

1andurel inspect domains --json
2andurel patterns show domain --entity Product --table products

inspect domains lists columns_ok, mismatches, and unpopulated_tags per entity. patterns show domain --entity prints the file manifest for a new entity, including the entity, receiver, Validate stub, and CRUD SQL when the generated row type already exists.