Arrays and lists as typed slices
On this page
The same on TimescaleDB. TimescaleDB runs this example unchanged: the SQL and the Go that sgl generates are identical to the PostgreSQL version. Only
dialect:in sgl.yaml changes.
Some values are lists by nature: the ports a voyage called at, the species in a haul. When the database stores them in an array column, Seagull maps that column to a Go slice of the element's type, so you get a []string or a []int64 to range over, never an array literal to parse or a []any to type-assert. Slices go back in as parameters the same way.
This example logs fishing voyages, each with the ports it called at, the catch it landed and, sometimes, what the crew saw on the way.
The schema
ports and catch_kg are arrays that cannot be NULL. sightings is an array that can.
db/migrations/0001_voyages.up.sql
CREATE TABLE voyages (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
boat TEXT NOT NULL,
ports TEXT[] NOT NULL,
catch_kg BIGINT[] NOT NULL,
sightings TEXT[]
);
-- sgl.column(voyages.ports, non_null_elements: true)
Reading arrays
ListVoyages selects the array columns as they are, and each becomes a typed slice:
internal/store
type ListVoyagesRow struct {
ID int64 `json:"id"`
Boat string `json:"boat"`
Ports []string `json:"ports"`
CatchKg []*int64 `json:"catch_kg"`
Sightings []*string `json:"sightings"`
}
func (queries *Queries) ListVoyages(ctx context.Context) ([]ListVoyagesRow, error) {
rows, err := queries.reader.QueryContext(ctx, listVoyagesSQL)
if err != nil {
return nil, err
}
defer rows.Close()
var results []ListVoyagesRow
for rows.Next() {
var row ListVoyagesRow
if err := rows.Scan(&row.ID, &row.Boat, sglScanInto(&row.Ports), sglScanInto(&row.CatchKg), sglScanInto(&row.Sightings)); err != nil {
return nil, err
}
results = append(results, row)
}
if err := rows.Err(); err != nil {
return nil, err
}
return results, nil
}
TEXT[] gives []string and BIGINT[] gives []int64. database/sql cannot scan a TimescaleDB array, so the generated SQL wraps each array column in to_json(), as in to_json(ports) AS "ports", and the method decodes the JSON into the slice. You write the column, and the generated code does the rest.
Sightings is nullable, but it is still a []string, not a pointer: a NULL array scans to a nil slice, and an empty array to an empty one, so the two stay apart.
Writing arrays
LogVoyage takes the same slices as parameters.
The driver sends each slice as a TimescaleDB array, and a nil slice as NULL. Voyage 2 passes nil sightings and gets NULL back; voyage 3 passes an empty slice and gets an empty array.
Searching inside an array
VoyagesCallingAt finds the voyages that called at one port, and reads two more things from each voyage's ports on the way.
db/queries/voyages.sql
-- sgl.query(VoyagesCallingAt, many)
-- $1 as sgl.param(port)
SELECT boat, ports[1] AS first_port, cardinality(ports) AS stops
FROM voyages
WHERE $1::text = ANY(ports)
ORDER BY id;
ANY(ports) compares the parameter with every element. On its own, a parameter compared with ANY could be anything, so Seagull would leave it as any; the cast in $1::text makes it a string, and CockroachDB needs the cast to type it too. ports[1] reads the first element, as arrays count from 1. It is a *string, because an element that is not there is NULL. cardinality(ports) counts the elements.
internal/store
type VoyagesCallingAtRow struct {
Boat string `json:"boat"`
FirstPort *string `json:"first_port"`
Stops int32 `json:"stops"`
}
func (queries *Queries) VoyagesCallingAt(ctx context.Context, port string) ([]VoyagesCallingAtRow, error) {
rows, err := queries.reader.QueryContext(ctx, voyagesCallingAtSQL, port)
if err != nil {
return nil, err
}
defer rows.Close()
var results []VoyagesCallingAtRow
for rows.Next() {
var row VoyagesCallingAtRow
if err := rows.Scan(&row.Boat, &row.FirstPort, &row.Stops); err != nil {
return nil, err
}
results = append(results, row)
}
if err := rows.Err(); err != nil {
return nil, err
}
return results, nil
}
Building an array
array_agg goes the other way, collecting a column from many rows into one array. Its result is a slice of its argument's type, so the voyage ids, a BIGINT column, come back as []int64:
db/queries/voyages.sql
-- sgl.query(VoyagesPerBoat, many)
SELECT boat, array_agg(id ORDER BY id) AS voyages
FROM voyages
GROUP BY boat
ORDER BY boat;
Using it
example.go
package main
import (
"context"
"database/sql"
"fmt"
"io"
"seagull.sh/seagull/examples/postgres/008_arrays/internal/store"
)
// run logs three fishing voyages and reads them back. An array column becomes a typed Go
// slice: TEXT[] gives []*string and BIGINT[] gives []*int64, and array_agg gives a slice
// of its argument's type, here []int64: the id column it collects is NOT NULL, so the
// array can hold no NULL and its elements are plain values. A slice parameter is sent as
// a PostgreSQL array, and a nil slice is sent as NULL.
//
// The elements are pointers because PostgreSQL can hold NULL in any array, whatever the
// column's type says, so a NULL element reads as nil, which elements prints as NULL.
// Parameters take the same pointers, so LogVoyage takes []*int64 for catch_kg and
// []*string for sightings, and a nil element writes NULL; values builds them from plain
// values. When the data never holds a NULL element, non_null_elements: true opts out, in
// an sgl.column line of a query or migration or as go.types.non_null_elements in
// sgl.yaml: the elements then read and bind as plain values, and a NULL one fails the
// read. The migration declares it for ports, as a voyage never calls at a NULL port, so
// Ports is a plain []string in the model, the row and the parameters alike.
//
// database/sql cannot scan a PostgreSQL array, so the generated SQL wraps each array
// column in to_json() and decodes the JSON into the slice; open internal/store to see it.
// A NULL array therefore scans to a nil slice, and an empty array to an empty one.
//
// VoyagesCallingAt searches inside an array with $1::text = ANY(ports). The cast gives
// the parameter its type, for Seagull, which would otherwise leave it as any, and for
// CockroachDB, which cannot work it out from ANY. ports[1] reads one element, and is
// *string because a missing element is NULL.
//
// Takes database (*sql.DB) which is a migrated database.
// Takes output (io.Writer) which receives the voyages and the searches.
//
// Returns error when a query fails.
func run(ctx context.Context, database *sql.DB, output io.Writer) error {
queries := store.New(database)
voyages := []store.LogVoyageParams{
{Boat: "Kittiwake", Ports: []string{"Newlyn", "St Ives", "Mousehole"}, CatchKg: values[int64](120, 85), Sightings: values("dolphin", "basking shark")},
{Boat: "Salty Chip", Ports: []string{"Penzance", "Mousehole"}, CatchKg: values[int64](40), Sightings: nil},
{Boat: "Kittiwake", Ports: []string{"Newlyn", "Lamorna"}, CatchKg: values[int64](60, 30, 15), Sightings: []*string{}},
}
for _, voyage := range voyages {
if err := queries.LogVoyage(ctx, voyage); err != nil {
return fmt.Errorf("logging %s's voyage: %w", voyage.Boat, err)
}
}
logged, err := queries.ListVoyages(ctx)
if err != nil {
return fmt.Errorf("listing voyages: %w", err)
}
for _, voyage := range logged {
sightings := elements(voyage.Sightings)
if voyage.Sightings == nil {
sightings = "NULL"
}
fmt.Fprintf(output, "voyage %d %-10s ports %s, catch %s kg, sightings %s\n", voyage.ID, voyage.Boat, voyage.Ports, elements(voyage.CatchKg), sightings)
}
calling, err := queries.VoyagesCallingAt(ctx, "Mousehole")
if err != nil {
return fmt.Errorf("finding voyages calling at Mousehole: %w", err)
}
fmt.Fprintln(output, "\ncalling at Mousehole:")
for _, voyage := range calling {
fmt.Fprintf(output, " %-10s from %s, %d stops\n", voyage.Boat, *voyage.FirstPort, voyage.Stops)
}
perBoat, err := queries.VoyagesPerBoat(ctx)
if err != nil {
return fmt.Errorf("grouping voyages by boat: %w", err)
}
fmt.Fprintln(output, "\nvoyages per boat:")
for _, boat := range perBoat {
fmt.Fprintf(output, " %-10s %v\n", boat.Boat, boat.Voyages)
}
return nil
}
// elements returns an array's elements as fmt prints a slice, with NULL for each element
// the database holds NULL in.
//
// Takes array ([]*T) which holds the elements, nil for a NULL one.
//
// Returns string which is the elements in brackets, separated by spaces.
func elements[T any](array []*T) string {
texts := make([]string, 0, len(array))
for _, element := range array {
if element == nil {
texts = append(texts, "NULL")
continue
}
texts = append(texts, fmt.Sprint(*element))
}
return fmt.Sprint(texts)
}
// values returns pointers to copies of plain values, as an array parameter whose elements
// may be NULL takes them.
//
// Takes items (...T) which are the elements.
//
// Returns []*T which points at the copies, none of them nil.
func values[T any](items ...T) []*T {
pointed := make([]*T, len(items))
for index := range items {
pointed[index] = &items[index]
}
return pointed
}
What it prints
voyage 1 Kittiwake ports [Newlyn St Ives Mousehole], catch [120 85] kg, sightings [dolphin basking shark]
voyage 2 Salty Chip ports [Penzance Mousehole], catch [40] kg, sightings NULL
voyage 3 Kittiwake ports [Newlyn Lamorna], catch [60 30 15] kg, sightings []
calling at Mousehole:
Kittiwake from Newlyn, 3 stops
Salty Chip from Penzance, 2 stops
voyages per boat:
Kittiwake [1 3]
Salty Chip [2]
To pass a Go slice where the SQL expects a list of values, as in IN (...), see IN lists from Go slices.
Across engines
Only these Go types of ListVoyages differ between engines; the rest are the same everywhere.
| PostgreSQL | DuckDB | |
|---|---|---|
ports column | []string | []*string |
catch_kg column | []*int64 | []*int32 |
CockroachDB and TimescaleDB run the PostgreSQL version unchanged.
Compare the whole example side by side with DuckDB.
Where the example is missing:
- Not on SQLite. SQLite has no array type.
- Not on MySQL. MySQL has no array type.
- Not on MariaDB. MariaDB has no array type.
Run it locally
From the directory you cloned the Seagull repository into:
cd seagull/examples/postgres && go run ./008_arrays
Its test runs it too, and checks what it prints against output.txt:
cd seagull/examples/postgres && SGL_EXAMPLE_ENGINE=timescaledb SGL_EXAMPLE_DSN="$DATABASE_URL" go test ./008_arrays
Needs a running TimescaleDB server: set DATABASE_URL, or pass -dsn, to the address of an empty database.
The files
Every file of the example, as it is in the Seagull repository.
sgl.yaml
# Copyright 2026 PolitePixels Limited
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# This project stands against fascism, authoritarianism, and all forms of
# oppression. We built this to empower people, not to enable those who would
# strip others of their rights and dignity.
version: 1
databases:
- name: voyages
dialect: postgres
migrations: db/migrations
queries: db/queries
go:
package: store
out: internal/store
driver: database/sql
db/migrations/0001_voyages.up.sql
CREATE TABLE voyages (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
boat TEXT NOT NULL,
ports TEXT[] NOT NULL,
catch_kg BIGINT[] NOT NULL,
sightings TEXT[]
);
-- sgl.column(voyages.ports, non_null_elements: true)
db/migrations/0001_voyages.down.sql
DROP TABLE voyages;
db/queries/voyages.sql
-- sgl.query(LogVoyage, exec)
INSERT INTO voyages (boat, ports, catch_kg, sightings) VALUES ($1, $2, $3, $4);
-- sgl.query(ListVoyages, many)
SELECT id, boat, ports, catch_kg, sightings FROM voyages ORDER BY id;
-- sgl.query(VoyagesCallingAt, many)
-- $1 as sgl.param(port)
SELECT boat, ports[1] AS first_port, cardinality(ports) AS stops
FROM voyages
WHERE $1::text = ANY(ports)
ORDER BY id;
-- sgl.query(VoyagesPerBoat, many)
SELECT boat, array_agg(id ORDER BY id) AS voyages
FROM voyages
GROUP BY boat
ORDER BY boat;
example.go
package main
import (
"context"
"database/sql"
"fmt"
"io"
"seagull.sh/seagull/examples/postgres/008_arrays/internal/store"
)
// run logs three fishing voyages and reads them back. An array column becomes a typed Go
// slice: TEXT[] gives []*string and BIGINT[] gives []*int64, and array_agg gives a slice
// of its argument's type, here []int64: the id column it collects is NOT NULL, so the
// array can hold no NULL and its elements are plain values. A slice parameter is sent as
// a PostgreSQL array, and a nil slice is sent as NULL.
//
// The elements are pointers because PostgreSQL can hold NULL in any array, whatever the
// column's type says, so a NULL element reads as nil, which elements prints as NULL.
// Parameters take the same pointers, so LogVoyage takes []*int64 for catch_kg and
// []*string for sightings, and a nil element writes NULL; values builds them from plain
// values. When the data never holds a NULL element, non_null_elements: true opts out, in
// an sgl.column line of a query or migration or as go.types.non_null_elements in
// sgl.yaml: the elements then read and bind as plain values, and a NULL one fails the
// read. The migration declares it for ports, as a voyage never calls at a NULL port, so
// Ports is a plain []string in the model, the row and the parameters alike.
//
// database/sql cannot scan a PostgreSQL array, so the generated SQL wraps each array
// column in to_json() and decodes the JSON into the slice; open internal/store to see it.
// A NULL array therefore scans to a nil slice, and an empty array to an empty one.
//
// VoyagesCallingAt searches inside an array with $1::text = ANY(ports). The cast gives
// the parameter its type, for Seagull, which would otherwise leave it as any, and for
// CockroachDB, which cannot work it out from ANY. ports[1] reads one element, and is
// *string because a missing element is NULL.
//
// Takes database (*sql.DB) which is a migrated database.
// Takes output (io.Writer) which receives the voyages and the searches.
//
// Returns error when a query fails.
func run(ctx context.Context, database *sql.DB, output io.Writer) error {
queries := store.New(database)
voyages := []store.LogVoyageParams{
{Boat: "Kittiwake", Ports: []string{"Newlyn", "St Ives", "Mousehole"}, CatchKg: values[int64](120, 85), Sightings: values("dolphin", "basking shark")},
{Boat: "Salty Chip", Ports: []string{"Penzance", "Mousehole"}, CatchKg: values[int64](40), Sightings: nil},
{Boat: "Kittiwake", Ports: []string{"Newlyn", "Lamorna"}, CatchKg: values[int64](60, 30, 15), Sightings: []*string{}},
}
for _, voyage := range voyages {
if err := queries.LogVoyage(ctx, voyage); err != nil {
return fmt.Errorf("logging %s's voyage: %w", voyage.Boat, err)
}
}
logged, err := queries.ListVoyages(ctx)
if err != nil {
return fmt.Errorf("listing voyages: %w", err)
}
for _, voyage := range logged {
sightings := elements(voyage.Sightings)
if voyage.Sightings == nil {
sightings = "NULL"
}
fmt.Fprintf(output, "voyage %d %-10s ports %s, catch %s kg, sightings %s\n", voyage.ID, voyage.Boat, voyage.Ports, elements(voyage.CatchKg), sightings)
}
calling, err := queries.VoyagesCallingAt(ctx, "Mousehole")
if err != nil {
return fmt.Errorf("finding voyages calling at Mousehole: %w", err)
}
fmt.Fprintln(output, "\ncalling at Mousehole:")
for _, voyage := range calling {
fmt.Fprintf(output, " %-10s from %s, %d stops\n", voyage.Boat, *voyage.FirstPort, voyage.Stops)
}
perBoat, err := queries.VoyagesPerBoat(ctx)
if err != nil {
return fmt.Errorf("grouping voyages by boat: %w", err)
}
fmt.Fprintln(output, "\nvoyages per boat:")
for _, boat := range perBoat {
fmt.Fprintf(output, " %-10s %v\n", boat.Boat, boat.Voyages)
}
return nil
}
// elements returns an array's elements as fmt prints a slice, with NULL for each element
// the database holds NULL in.
//
// Takes array ([]*T) which holds the elements, nil for a NULL one.
//
// Returns string which is the elements in brackets, separated by spaces.
func elements[T any](array []*T) string {
texts := make([]string, 0, len(array))
for _, element := range array {
if element == nil {
texts = append(texts, "NULL")
continue
}
texts = append(texts, fmt.Sprint(*element))
}
return fmt.Sprint(texts)
}
// values returns pointers to copies of plain values, as an array parameter whose elements
// may be NULL takes them.
//
// Takes items (...T) which are the elements.
//
// Returns []*T which points at the copies, none of them nil.
func values[T any](items ...T) []*T {
pointed := make([]*T, len(items))
for index := range items {
pointed[index] = &items[index]
}
return pointed
}
main.go
// Command 008_arrays applies its migrations to a PostgreSQL database, then runs the
// example in example.go: fishing voyages are logged with array columns, read back as Go
// slices and searched with ANY.
//
// Run it with `go run . -dsn postgres://user:password@localhost:5432/example`, or set
// DATABASE_URL instead of passing -dsn. Give it an empty database of its own, as the
// migrations create its tables and the example adds rows each time it runs.
package main
import (
"context"
"database/sql"
"embed"
"errors"
"flag"
"fmt"
"log"
"os"
_ "github.com/jackc/pgx/v5/stdlib"
"seagull.sh/seagull/sdk/migrate"
"seagull.sh/seagull/sdk/migrate/migrate_backend_postgres"
)
// migrations holds the migration files, compiled into the binary.
//
//go:embed db/migrations
var migrations embed.FS
func main() {
dsn := flag.String("dsn", os.Getenv("DATABASE_URL"), "PostgreSQL connection string; defaults to $DATABASE_URL")
flag.Parse()
if err := execute(context.Background(), *dsn); err != nil {
log.Fatal(err)
}
}
// execute opens and migrates the database at dsn, and runs the example against it.
//
// Takes dsn (string) which is the PostgreSQL connection string.
//
// Returns error when the database cannot be prepared or the example fails.
func execute(ctx context.Context, dsn string) error {
if dsn == "" {
return errors.New("set -dsn or DATABASE_URL to a PostgreSQL connection string")
}
database, err := sql.Open("pgx", dsn)
if err != nil {
return err
}
defer func() { _ = database.Close() }()
migrator, err := migrate.NewMigrator(database, migrate_backend_postgres.New(), migrations, "db/migrations")
if err != nil {
return err
}
if _, err := migrator.Up(ctx); err != nil {
return fmt.Errorf("migrating: %w", err)
}
return run(ctx, database, os.Stdout)
}
internal/store/models.go
// Code generated by sgl. DO NOT EDIT.
package store
type Voyages struct {
ID int64 `json:"id"`
Boat string `json:"boat"`
Ports []string `json:"ports"`
CatchKg []*int64 `json:"catch_kg"`
Sightings []*string `json:"sightings"`
}
internal/store/querier.go
// Code generated by sgl. DO NOT EDIT.
package store
import (
"context"
"database/sql"
)
type DBTX interface {
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
}
type dbtxWrapper interface {
WrapDBTX(inner any) any
}
type Queries struct {
reader DBTX
writer DBTX
wrapper dbtxWrapper
}
func New(db DBTX) *Queries {
wrapper, _ := db.(dbtxWrapper)
return &Queries{reader: db, writer: db, wrapper: wrapper}
}
func NewWithReplica(writer DBTX, reader DBTX) *Queries {
wrapper, _ := writer.(dbtxWrapper)
return &Queries{reader: reader, writer: writer, wrapper: wrapper}
}
func (queries *Queries) WithTx(transaction *sql.Tx) *Queries {
if queries == nil {
return &Queries{reader: transaction, writer: transaction, wrapper: nil}
}
db := queries.wrapDBTX(transaction)
return &Queries{reader: db, writer: db, wrapper: queries.wrapper}
}
func (queries *Queries) wrapDBTX(db DBTX) DBTX {
if queries.wrapper == nil {
return db
}
wrapped, ok := queries.wrapper.WrapDBTX(db).(DBTX)
if !ok {
return db
}
return wrapped
}
func (queries *Queries) RunInTx(ctx context.Context, db *sql.DB, fn func(*Queries) error) error {
transaction, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer transaction.Rollback()
if err = fn(queries.WithTx(transaction)); err != nil {
return err
}
return transaction.Commit()
}
internal/store/sgl_json.go
// Code generated by sgl. DO NOT EDIT.
package store
import (
"bytes"
"database/sql"
"encoding"
"encoding/hex"
"encoding/json"
"fmt"
"reflect"
"slices"
"strings"
"time"
)
func sglScanInto[T any](dest *T) sql.Scanner {
return sglJSONScanner[T]{dest: dest}
}
type sglJSONScanner[T any] struct {
dest *T
}
func (s sglJSONScanner[T]) Scan(source any) error {
var raw []byte
switch value := source.(type) {
case nil:
var zero T
*s.dest = zero
return nil
case []byte:
raw = value
case string:
raw = []byte(value)
default:
encoded, err := json.Marshal(value)
if err != nil {
return fmt.Errorf("sgl: re-encode %T to JSON: %w", source, err)
}
raw = encoded
}
if len(raw) == 0 {
var zero T
*s.dest = zero
return nil
}
if err := sglDecodeJSONColumn(raw, s.dest); err != nil {
var zero T
*s.dest = zero
return fmt.Errorf("sgl: decode JSON column: %w", err)
}
return nil
}
func sglDecodeJSONColumn[T any](raw []byte, dest *T) error {
fast := !bytes.Contains(raw, []byte("null"))
var decodeErr error
if fast {
decodeErr = json.Unmarshal(raw, dest)
if decodeErr == nil {
return nil
}
}
document, err := sglJSONDocument(raw)
if err != nil {
return err
}
if !fast {
decodeErr = json.Unmarshal(raw, dest)
}
if decodeErr != nil && !sglDecodeEngineJSON(document, dest) {
return decodeErr
}
return sglJSONNulls(document, reflect.TypeFor[T]())
}
func sglJSONDocument(raw []byte) (any, error) {
var document any
if !json.Valid(raw) {
return nil, json.Unmarshal(raw, &document)
}
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.UseNumber()
if err := decoder.Decode(&document); err != nil {
return nil, err
}
return document, nil
}
func sglDecodeEngineJSON[T any](document any, dest *T) bool {
rewritten, err := json.Marshal(sglEngineJSONValue(document, reflect.TypeFor[T]()))
if err != nil {
return false
}
var zero T
*dest = zero
return json.Unmarshal(rewritten, dest) == nil
}
func sglEngineJSONValue(value any, target reflect.Type) any {
for target.Kind() == reflect.Pointer {
target = target.Elem()
}
switch typed := value.(type) {
case []any:
if target.Kind() == reflect.Slice || target.Kind() == reflect.Array {
return sglEngineJSONElements(typed, target.Elem())
}
case map[string]any:
if target.Kind() == reflect.Map {
rewritten := make(map[string]any, len(typed))
for key, element := range typed {
rewritten[key] = sglEngineJSONValue(element, target.Elem())
}
return rewritten
}
case json.Number:
if target.Kind() == reflect.String || sglUnmarshalsText(target) {
return string(typed)
}
case string:
return sglEngineJSONText(typed, target)
}
return value
}
func sglEngineJSONElements(elements []any, target reflect.Type) []any {
rewritten := make([]any, len(elements))
for index, element := range elements {
rewritten[index] = sglEngineJSONValue(element, target)
}
return rewritten
}
func sglEngineJSONText(text string, target reflect.Type) any {
switch {
case target == reflect.TypeFor[time.Time]():
for _, layout := range []string{"2006-01-02T15:04:05.999999999", time.DateOnly} {
if moment, err := time.ParseInLocation(layout, text, time.UTC); err == nil {
return moment.Format(time.RFC3339Nano)
}
}
case target.Kind() == reflect.Slice && target.Elem().Kind() == reflect.Uint8 && !sglUnmarshalsJSON(target):
if digits, found := strings.CutPrefix(text, `\x`); found {
if decoded, err := hex.DecodeString(digits); err == nil {
return decoded
}
}
}
return text
}
func sglUnmarshalsText(target reflect.Type) bool {
return reflect.PointerTo(target).Implements(reflect.TypeFor[encoding.TextUnmarshaler]()) && !sglUnmarshalsJSON(target)
}
func sglUnmarshalsJSON(target reflect.Type) bool {
return reflect.PointerTo(target).Implements(reflect.TypeFor[json.Unmarshaler]())
}
func sglJSONNulls(document any, target reflect.Type) error {
for target.Kind() == reflect.Pointer {
target = target.Elem()
}
switch typed := document.(type) {
case []any:
if target.Kind() == reflect.Slice || target.Kind() == reflect.Array {
return sglJSONElementNulls(typed, target.Elem())
}
case map[string]any:
if target.Kind() == reflect.Map {
return sglJSONValueNulls(typed, target.Elem())
}
}
return nil
}
func sglJSONElementNulls(elements []any, element reflect.Type) error {
for index, value := range elements {
if err := sglJSONNull(value, element); err != nil {
return fmt.Errorf("element %d: %w", index, err)
}
}
return nil
}
func sglJSONValueNulls(values map[string]any, element reflect.Type) error {
keys := make([]string, 0, len(values))
for key := range values {
keys = append(keys, key)
}
slices.Sort(keys)
for _, key := range keys {
if err := sglJSONNull(values[key], element); err != nil {
return fmt.Errorf("value of key %s: %w", key, err)
}
}
return nil
}
func sglJSONNull(value any, target reflect.Type) error {
if value != nil {
return sglJSONNulls(value, target)
}
switch target.Kind() {
case reflect.Pointer, reflect.Interface, reflect.Slice, reflect.Map:
return nil
}
if reflect.PointerTo(target).Implements(reflect.TypeFor[json.Unmarshaler]()) {
return nil
}
return fmt.Errorf("converting NULL to %s is unsupported", target)
}
internal/store/voyages.sql.go
// Code generated by sgl. DO NOT EDIT.
package store
import "context"
const listVoyagesSQL = `SELECT id, boat, to_json(ports) AS "ports", to_json(catch_kg) AS "catch_kg", to_json(sightings) AS "sightings" FROM voyages ORDER BY id;`
type ListVoyagesRow struct {
ID int64 `json:"id"`
Boat string `json:"boat"`
Ports []string `json:"ports"`
CatchKg []*int64 `json:"catch_kg"`
Sightings []*string `json:"sightings"`
}
func (queries *Queries) ListVoyages(ctx context.Context) ([]ListVoyagesRow, error) {
rows, err := queries.reader.QueryContext(ctx, listVoyagesSQL)
if err != nil {
return nil, err
}
defer rows.Close()
var results []ListVoyagesRow
for rows.Next() {
var row ListVoyagesRow
if err := rows.Scan(&row.ID, &row.Boat, sglScanInto(&row.Ports), sglScanInto(&row.CatchKg), sglScanInto(&row.Sightings)); err != nil {
return nil, err
}
results = append(results, row)
}
if err := rows.Err(); err != nil {
return nil, err
}
return results, nil
}
const logVoyageSQL = `INSERT INTO voyages (boat, ports, catch_kg, sightings) VALUES ($1, $2, $3, $4);`
type LogVoyageParams struct {
Boat string
Ports []string
CatchKg []*int64
Sightings []*string
}
func (queries *Queries) LogVoyage(ctx context.Context, params LogVoyageParams) error {
_, err := queries.writer.ExecContext(ctx, logVoyageSQL, params.Boat, params.Ports, params.CatchKg, params.Sightings)
return err
}
const voyagesCallingAtSQL = `SELECT boat, ports[1] AS first_port, cardinality(ports) AS stops
FROM voyages
WHERE $1::text = ANY(ports)
ORDER BY id;`
type VoyagesCallingAtRow struct {
Boat string `json:"boat"`
FirstPort *string `json:"first_port"`
Stops int32 `json:"stops"`
}
func (queries *Queries) VoyagesCallingAt(ctx context.Context, port string) ([]VoyagesCallingAtRow, error) {
rows, err := queries.reader.QueryContext(ctx, voyagesCallingAtSQL, port)
if err != nil {
return nil, err
}
defer rows.Close()
var results []VoyagesCallingAtRow
for rows.Next() {
var row VoyagesCallingAtRow
if err := rows.Scan(&row.Boat, &row.FirstPort, &row.Stops); err != nil {
return nil, err
}
results = append(results, row)
}
if err := rows.Err(); err != nil {
return nil, err
}
return results, nil
}
const voyagesPerBoatSQL = `SELECT boat, to_json(array_agg(id ORDER BY id)) AS voyages
FROM voyages
GROUP BY boat
ORDER BY boat;`
type VoyagesPerBoatRow struct {
Boat string `json:"boat"`
Voyages []int64 `json:"voyages"`
}
func (queries *Queries) VoyagesPerBoat(ctx context.Context) ([]VoyagesPerBoatRow, error) {
rows, err := queries.reader.QueryContext(ctx, voyagesPerBoatSQL)
if err != nil {
return nil, err
}
defer rows.Close()
var results []VoyagesPerBoatRow
for rows.Next() {
var row VoyagesPerBoatRow
if err := rows.Scan(&row.Boat, sglScanInto(&row.Voyages)); err != nil {
return nil, err
}
results = append(results, row)
}
if err := rows.Err(); err != nil {
return nil, err
}
return results, nil
}
output.txt
voyage 1 Kittiwake ports [Newlyn St Ives Mousehole], catch [120 85] kg, sightings [dolphin basking shark]
voyage 2 Salty Chip ports [Penzance Mousehole], catch [40] kg, sightings NULL
voyage 3 Kittiwake ports [Newlyn Lamorna], catch [60 30 15] kg, sightings []
calling at Mousehole:
Kittiwake from Newlyn, 3 stops
Salty Chip from Penzance, 2 stops
voyages per boat:
Kittiwake [1 3]
Salty Chip [2]