Nested types in DuckDB

On this page

On DuckDB:

  • A LIST of a scalar is a typed slice, a STRUCT a struct with a field per field, a LIST of STRUCT a slice of those structs, and a MAP a Go map
  • sgl.yaml sets go.types.non_null_elements: true, as the marina never stores NULL inside a list or map, so elements and map values are plain values rather than the default pointers
  • Each STRUCT is a struct type in models.go named after its table and column, store.BoatsBerth, or store.BoatsRouteItem for the route's elements; DuckDB lets a STRUCT field be NULL, so its fields are pointers
  • The generated SQL selects LIST, STRUCT and MAP columns as written, and the generated code reads the values duckdb-go returns into the typed fields
  • The generated code converts a struct, a slice of structs or a map into what duckdb-go binds, and a nil one binds NULL
  • A UNION column is the generated JSON type, holding duckdb-go's Union encoded as {"value", "tag"}
  • The JSON type cannot bind a union, so the writes cast a value to one member's type
  • crew[1], map_keys(fees) and fees['August'] give typed, nullable results

DuckDB columns can hold structure: a STRUCT of named fields, a LIST of values, a MAP from keys to values, and a UNION that holds one of several types. Data that would otherwise need extra tables, or a JSON blob with no types at all, fits in one row. Seagull reads these types from the migration and gives each a Go type, and an expression that reaches inside one, such as a subscript, gets a precise type of its own.

The example is a marina: each boat has a berth, a crew, a planned route, a fee for each month and a way to be contacted.

The table

db/migrations/0001_boats.up.sql

CREATE SEQUENCE boat_ids;

CREATE TABLE boats (
    id      BIGINT                                    PRIMARY KEY DEFAULT nextval('boat_ids'),
    name    VARCHAR                                   NOT NULL UNIQUE,
    berth   STRUCT(pontoon VARCHAR, number INTEGER)   NOT NULL,
    crew    VARCHAR[]                                 NOT NULL,
    route   STRUCT(lat DOUBLE, lon DOUBLE)[]          NOT NULL,
    fees    MAP(VARCHAR, INTEGER)                     NOT NULL,
    contact UNION(phone VARCHAR, vhf_channel INTEGER)
);

How each type maps

Seagull maps the nested types like this:

  • A LIST of a scalar, such as crew VARCHAR[], is a typed slice, []string.
  • A STRUCT, berth, is a Go struct with a field for each of its fields, Pontoon string and Number int32, tagged with the field's DuckDB name.
  • A LIST of STRUCT, route, is a slice of such structs, one per stop.
  • A MAP, fees MAP(VARCHAR, INTEGER), is a Go map, map[string]int32.
  • A UNION, contact, is the package's generated JSON type, holding the member's tag and value as {"value": ..., "tag": ...}. The column may be NULL, and a nil JSON is NULL.

You can see all of it in the row type for a query that selects every column:

db/queries/boats.sql

-- sgl.query(ListBoats, many)
SELECT id, name, berth, crew, route, fees, contact FROM boats ORDER BY id;

internal/store

type ListBoatsRow struct {
	ID      int64            `json:"id"`
	Name    string           `json:"name"`
	Berth   BoatsBerth       `json:"berth"`
	Crew    []string         `json:"crew"`
	Route   []BoatsRouteItem `json:"route"`
	Fees    map[string]int32 `json:"fees"`
	Contact JSON             `json:"contact"`
}

type BoatsBerth struct {
	Pontoon *string `json:"pontoon"`
	Number  *int32  `json:"number"`
}

type BoatsRouteItem struct {
	Lat *float64 `json:"lat"`
	Lon *float64 `json:"lon"`
}

type JSON []byte

func (queries *Queries) ListBoats(ctx context.Context) ([]ListBoatsRow, error) {
	rows, err := queries.reader.QueryContext(ctx, listBoatsSQL)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var results []ListBoatsRow
	for rows.Next() {
		var row ListBoatsRow
		if err := rows.Scan(&row.ID, &row.Name, sglScanDriverComposite(&row.Berth), sglScanDriverComposite(&row.Crew), sglScanDriverComposite(&row.Route), sglScanDriverComposite(&row.Fees), &row.Contact); err != nil {
			return nil, err
		}
		results = append(results, row)
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}
	return results, nil
}

The method's SQL is not quite the query you wrote: the generated constant wraps the list, struct and map columns in to_json(), and the method decodes that JSON into the typed fields.

The struct types are anonymous: the generated field is Berth struct { Pontoon string ...; Number int32 ... }, not a named type. To write one as a literal, the example declares a type alias to the same struct, tags included, which is the same type:

type berth = struct {
	Pontoon string `json:"pontoon"`
	Number  int32  `json:"number"`
}

Writing nested values

MoorBoat takes the same Go types for the nested columns: a struct for the berth, slices for the crew and route, and a map for the fees. duckdb-go cannot bind a Go struct or map directly, so the generated method passes each through sglDriverComposite, which turns a struct into the field-keyed map the driver binds as a STRUCT, a map into the driver's duckdb.Map, and a nil one into NULL. The program needs nothing from duckdb-go itself.

db/queries/boats.sql

-- sgl.query(MoorBoat, exec)
INSERT INTO boats (name, berth, crew, route, fees) VALUES ($1, $2, $3, $4, $5);

The JSON type cannot bind a UNION, so the contact is written by casting the value to one member's type, which DuckDB converts to that member. A phone number is cast to VARCHAR and a radio channel to INTEGER, so there are two queries, each naming its parameter with sgl.param:

db/queries/boats.sql

-- sgl.query(ContactByPhone, execrows)
-- $1 as sgl.param(phone)
UPDATE boats SET contact = $1::VARCHAR WHERE name = $2;

db/queries/boats.sql

-- sgl.query(ContactByRadio, execrows)
-- $1 as sgl.param(channel)
UPDATE boats SET contact = $1::INTEGER WHERE name = $2;

Reaching inside

Expressions that reach into a nested column come back typed, and nullable where they can miss:

db/queries/boats.sql

-- sgl.query(BoatSummaries, many)
SELECT
    name,
    crew[1] AS skipper,
    array_length(crew) AS crew_size,
    map_keys(fees) AS months,
    fees['August'] AS august_fee
FROM boats
ORDER BY id;
  • crew[1] is the first crew member, a *string, because a list may be shorter than the index (DuckDB lists count from 1).
  • array_length(crew) is an int64.
  • map_keys(fees) is a []string of the months that have a fee. A Go map keeps no order, so the keys reach DuckDB in a different order on each write, and the program sorts the months before printing them.
  • fees['August'] is a *int32, NULL when the map has no August key.

internal/store

type BoatSummariesRow struct {
	Name      string   `json:"name"`
	Skipper   *string  `json:"skipper"`
	CrewSize  int64    `json:"crew_size"`
	Months    []string `json:"months"`
	AugustFee *int32   `json:"august_fee"`
}

func (queries *Queries) BoatSummaries(ctx context.Context) ([]BoatSummariesRow, error) {
	rows, err := queries.reader.QueryContext(ctx, boatSummariesSQL)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var results []BoatSummariesRow
	for rows.Next() {
		var row BoatSummariesRow
		if err := rows.Scan(&row.Name, &row.Skipper, &row.CrewSize, sglScanDriverComposite(&row.Months), &row.AugustFee); err != nil {
			return nil, err
		}
		results = append(results, row)
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}
	return results, nil
}

Using it

example.go

package main

import (
	"context"
	"database/sql"
	"encoding/json"
	"fmt"
	"io"
	"slices"

	"seagull.sh/seagull/examples/duckdb/037_duckdb_nested_types/internal/store"
)

// contact is the document the generated JSON type holds for a UNION value: the member's
// tag and its value.
type contact struct {
	// Tag names the union member that is set, phone or vhf_channel.
	Tag string `json:"tag"`

	// Value is the member's value.
	Value any `json:"value"`
}

// run moors two boats in a marina whose boats table has a column of each nested DuckDB
// type, then reads them back whole and through typed expressions. Seagull types each one
// from its declaration:
//
//   - a LIST of a scalar, crew VARCHAR[], is a typed slice, []string;
//   - a STRUCT, berth, is a struct type that models.go declares, named after the table
//     and column that hold it, store.BoatsBerth, with a field for each of its fields.
//     DuckDB lets any field of a STRUCT be NULL, so each field is a pointer, which the
//     program fills with new;
//   - a LIST of STRUCT, route, is a slice of such a type, []store.BoatsRouteItem;
//   - a MAP, fees MAP(VARCHAR, INTEGER), is map[string]int32. The generated code copies a
//     map into the driver's own map type when it binds one, and a nil map binds NULL;
//   - a UNION, contact, is the generated JSON type: duckdb-go reads a duckdb.Union, which
//     JSON holds encoded as {"value": ..., "tag": ...}, and a nil JSON is NULL. The JSON
//     type cannot bind a union, so ContactByPhone and ContactByRadio write one by casting
//     the value to a member's type, VARCHAR or INTEGER, which DuckDB converts to that
//     member.
//
// DuckDB can hold NULL in any list element or map value, so these would be pointers, as
// 008_arrays shows; this marina never stores one there, so sgl.yaml declares them
// non-null with go.types.non_null_elements: true, and they read as plain values.
//
// The generated SQL selects the LIST, STRUCT and MAP columns as they are, and the
// generated code reads the values duckdb-go returns for them into the typed fields.
//
// BoatSummaries reaches inside the columns, and those results are typed: crew[1] is
// *string, as a list may be shorter, array_length is an int64, map_keys gives []string,
// and fees['August'] is *int32, NULL when the map has no such key. A Go map keeps no
// order, so the order DuckDB stores its keys in differs from one write to the next, and
// the program sorts the months before printing them.
//
// Takes database (*sql.DB) which is a migrated database.
// Takes output (io.Writer) which receives each boat and the summaries.
//
// Returns error when a query fails or a value has an unexpected shape.
func run(ctx context.Context, database *sql.DB, output io.Writer) error {
	queries := store.New(database)

	boats := []store.MoorBoatParams{
		{
			Name:  "Kittiwake",
			Berth: store.BoatsBerth{Pontoon: new("A"), Number: new(int32(12))},
			Crew:  []string{"Morwenna", "Jago"},
			Route: []store.BoatsRouteItem{{Lat: new(50.10), Lon: new(-5.54)}, {Lat: new(50.08), Lon: new(-5.55)}},
			Fees:  map[string]int32{"July": 300, "August": 450},
		},
		{
			Name:  "Salty Chip",
			Berth: store.BoatsBerth{Pontoon: new("B"), Number: new(int32(3))},
			Crew:  []string{"Gwen"},
			Route: []store.BoatsRouteItem{},
			Fees:  map[string]int32{"July": 180},
		},
	}
	for _, boat := range boats {
		if err := queries.MoorBoat(ctx, boat); err != nil {
			return fmt.Errorf("mooring %s: %w", boat.Name, err)
		}
	}
	if _, err := queries.ContactByPhone(ctx, store.ContactByPhoneParams{Phone: "07700 900123", Name: "Kittiwake"}); err != nil {
		return fmt.Errorf("setting Kittiwake's phone: %w", err)
	}
	if _, err := queries.ContactByRadio(ctx, store.ContactByRadioParams{Channel: 16, Name: "Salty Chip"}); err != nil {
		return fmt.Errorf("setting Salty Chip's radio channel: %w", err)
	}

	moored, err := queries.ListBoats(ctx)
	if err != nil {
		return fmt.Errorf("listing boats: %w", err)
	}
	for _, boat := range moored {
		reached := contact{Tag: "nothing", Value: "no contact"}
		if boat.Contact != nil {
			if err := json.Unmarshal(boat.Contact, &reached); err != nil {
				return fmt.Errorf("decoding %s's contact: %w", boat.Name, err)
			}
		}
		fmt.Fprintln(output, boat.Name)
		fmt.Fprintf(output, "  berth    %-23T pontoon %s, berth %s\n", boat.Berth, nullable(boat.Berth.Pontoon), nullable(boat.Berth.Number))
		fmt.Fprintf(output, "  crew     %-23T %v\n", boat.Crew, boat.Crew)
		fmt.Fprintf(output, "  route    %-23T %v\n", boat.Route, stops(boat.Route))
		fmt.Fprintf(output, "  fees     %-23T %v\n", boat.Fees, boat.Fees)
		fmt.Fprintf(output, "  contact  %-23T %s, so by %s: %v\n", boat.Contact, boat.Contact, reached.Tag, reached.Value)
	}

	summaries, err := queries.BoatSummaries(ctx)
	if err != nil {
		return fmt.Errorf("summarising boats: %w", err)
	}
	fmt.Fprintln(output, "summaries:")
	for _, summary := range summaries {
		slices.Sort(summary.Months)
		augustFee := "none"
		if summary.AugustFee != nil {
			augustFee = fmt.Sprint(*summary.AugustFee)
		}
		fmt.Fprintf(output, "  %-10s skipper %s, crew of %d, fees for %v, August %s\n", summary.Name, *summary.Skipper, summary.CrewSize, summary.Months, augustFee)
	}
	return nil
}

// stops returns a route's stops, each as its latitude and longitude.
//
// Takes route ([]store.BoatsRouteItem) which is the route to describe.
//
// Returns []string which holds one latitude,longitude pair per stop.
func stops(route []store.BoatsRouteItem) []string {
	pairs := make([]string, 0, len(route))
	for _, stop := range route {
		pairs = append(pairs, nullable(stop.Lat)+","+nullable(stop.Lon))
	}
	return pairs
}

// nullable returns the value a nullable STRUCT field holds, or NULL when it holds none.
//
// Takes value (*T) which is the field.
//
// Returns string which is the value as text.
func nullable[T any](value *T) string {
	if value == nil {
		return "NULL"
	}
	return fmt.Sprint(*value)
}

What it prints

For each boat, the Go type of every nested field is printed beside its value, with a struct's fields left out of its name. The contact is decoded from its JSON to show which member of the union is set. Salty Chip pays no fee in August, so its august_fee is NULL.

Kittiwake
  berth    store.BoatsBerth        pontoon A, berth 12
  crew     []string                [Morwenna Jago]
  route    []store.BoatsRouteItem  [50.1,-5.54 50.08,-5.55]
  fees     map[string]int32        map[August:450 July:300]
  contact  store.JSON              {"value":"07700 900123","tag":"phone"}, so by phone: 07700 900123
Salty Chip
  berth    store.BoatsBerth        pontoon B, berth 3
  crew     []string                [Gwen]
  route    []store.BoatsRouteItem  []
  fees     map[string]int32        map[July:180]
  contact  store.JSON              {"value":16,"tag":"vhf_channel"}, so by vhf_channel: 16
summaries:
  Kittiwake  skipper Morwenna, crew of 2, fees for [August July], August 450
  Salty Chip skipper Gwen, crew of 1, fees for [July], August none

Try it

Add crew[2] AS mate to the select list of BoatSummaries and run sgl generate. Mate is a *string like Skipper, and here the pointer earns its keep: Salty Chip's crew is one person, so DuckDB returns NULL for the second, and the field is nil.

Run it locally

From the directory you cloned the Seagull repository into:

cd seagull/examples/duckdb && go run ./037_duckdb_nested_types

Its test runs it too, and checks what it prints against output.txt:

cd seagull/examples/duckdb && go test ./037_duckdb_nested_types

Needs cgo and a C compiler (the DuckDB driver).

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: marina
    dialect: duckdb
    migrations: db/migrations
    queries: db/queries
    go:
      package: store
      out: internal/store
      types:
        non_null_elements: true

db/migrations/0001_boats.up.sql

CREATE SEQUENCE boat_ids;

CREATE TABLE boats (
    id      BIGINT                                    PRIMARY KEY DEFAULT nextval('boat_ids'),
    name    VARCHAR                                   NOT NULL UNIQUE,
    berth   STRUCT(pontoon VARCHAR, number INTEGER)   NOT NULL,
    crew    VARCHAR[]                                 NOT NULL,
    route   STRUCT(lat DOUBLE, lon DOUBLE)[]          NOT NULL,
    fees    MAP(VARCHAR, INTEGER)                     NOT NULL,
    contact UNION(phone VARCHAR, vhf_channel INTEGER)
);

db/migrations/0001_boats.down.sql

DROP TABLE boats;
DROP SEQUENCE boat_ids;

db/queries/boats.sql

-- sgl.query(MoorBoat, exec)
INSERT INTO boats (name, berth, crew, route, fees) VALUES ($1, $2, $3, $4, $5);

-- sgl.query(ContactByPhone, execrows)
-- $1 as sgl.param(phone)
UPDATE boats SET contact = $1::VARCHAR WHERE name = $2;

-- sgl.query(ContactByRadio, execrows)
-- $1 as sgl.param(channel)
UPDATE boats SET contact = $1::INTEGER WHERE name = $2;

-- sgl.query(ListBoats, many)
SELECT id, name, berth, crew, route, fees, contact FROM boats ORDER BY id;

-- sgl.query(BoatSummaries, many)
SELECT
    name,
    crew[1] AS skipper,
    array_length(crew) AS crew_size,
    map_keys(fees) AS months,
    fees['August'] AS august_fee
FROM boats
ORDER BY id;

example.go

package main

import (
	"context"
	"database/sql"
	"encoding/json"
	"fmt"
	"io"
	"slices"

	"seagull.sh/seagull/examples/duckdb/037_duckdb_nested_types/internal/store"
)

// contact is the document the generated JSON type holds for a UNION value: the member's
// tag and its value.
type contact struct {
	// Tag names the union member that is set, phone or vhf_channel.
	Tag string `json:"tag"`

	// Value is the member's value.
	Value any `json:"value"`
}

// run moors two boats in a marina whose boats table has a column of each nested DuckDB
// type, then reads them back whole and through typed expressions. Seagull types each one
// from its declaration:
//
//   - a LIST of a scalar, crew VARCHAR[], is a typed slice, []string;
//   - a STRUCT, berth, is a struct type that models.go declares, named after the table
//     and column that hold it, store.BoatsBerth, with a field for each of its fields.
//     DuckDB lets any field of a STRUCT be NULL, so each field is a pointer, which the
//     program fills with new;
//   - a LIST of STRUCT, route, is a slice of such a type, []store.BoatsRouteItem;
//   - a MAP, fees MAP(VARCHAR, INTEGER), is map[string]int32. The generated code copies a
//     map into the driver's own map type when it binds one, and a nil map binds NULL;
//   - a UNION, contact, is the generated JSON type: duckdb-go reads a duckdb.Union, which
//     JSON holds encoded as {"value": ..., "tag": ...}, and a nil JSON is NULL. The JSON
//     type cannot bind a union, so ContactByPhone and ContactByRadio write one by casting
//     the value to a member's type, VARCHAR or INTEGER, which DuckDB converts to that
//     member.
//
// DuckDB can hold NULL in any list element or map value, so these would be pointers, as
// 008_arrays shows; this marina never stores one there, so sgl.yaml declares them
// non-null with go.types.non_null_elements: true, and they read as plain values.
//
// The generated SQL selects the LIST, STRUCT and MAP columns as they are, and the
// generated code reads the values duckdb-go returns for them into the typed fields.
//
// BoatSummaries reaches inside the columns, and those results are typed: crew[1] is
// *string, as a list may be shorter, array_length is an int64, map_keys gives []string,
// and fees['August'] is *int32, NULL when the map has no such key. A Go map keeps no
// order, so the order DuckDB stores its keys in differs from one write to the next, and
// the program sorts the months before printing them.
//
// Takes database (*sql.DB) which is a migrated database.
// Takes output (io.Writer) which receives each boat and the summaries.
//
// Returns error when a query fails or a value has an unexpected shape.
func run(ctx context.Context, database *sql.DB, output io.Writer) error {
	queries := store.New(database)

	boats := []store.MoorBoatParams{
		{
			Name:  "Kittiwake",
			Berth: store.BoatsBerth{Pontoon: new("A"), Number: new(int32(12))},
			Crew:  []string{"Morwenna", "Jago"},
			Route: []store.BoatsRouteItem{{Lat: new(50.10), Lon: new(-5.54)}, {Lat: new(50.08), Lon: new(-5.55)}},
			Fees:  map[string]int32{"July": 300, "August": 450},
		},
		{
			Name:  "Salty Chip",
			Berth: store.BoatsBerth{Pontoon: new("B"), Number: new(int32(3))},
			Crew:  []string{"Gwen"},
			Route: []store.BoatsRouteItem{},
			Fees:  map[string]int32{"July": 180},
		},
	}
	for _, boat := range boats {
		if err := queries.MoorBoat(ctx, boat); err != nil {
			return fmt.Errorf("mooring %s: %w", boat.Name, err)
		}
	}
	if _, err := queries.ContactByPhone(ctx, store.ContactByPhoneParams{Phone: "07700 900123", Name: "Kittiwake"}); err != nil {
		return fmt.Errorf("setting Kittiwake's phone: %w", err)
	}
	if _, err := queries.ContactByRadio(ctx, store.ContactByRadioParams{Channel: 16, Name: "Salty Chip"}); err != nil {
		return fmt.Errorf("setting Salty Chip's radio channel: %w", err)
	}

	moored, err := queries.ListBoats(ctx)
	if err != nil {
		return fmt.Errorf("listing boats: %w", err)
	}
	for _, boat := range moored {
		reached := contact{Tag: "nothing", Value: "no contact"}
		if boat.Contact != nil {
			if err := json.Unmarshal(boat.Contact, &reached); err != nil {
				return fmt.Errorf("decoding %s's contact: %w", boat.Name, err)
			}
		}
		fmt.Fprintln(output, boat.Name)
		fmt.Fprintf(output, "  berth    %-23T pontoon %s, berth %s\n", boat.Berth, nullable(boat.Berth.Pontoon), nullable(boat.Berth.Number))
		fmt.Fprintf(output, "  crew     %-23T %v\n", boat.Crew, boat.Crew)
		fmt.Fprintf(output, "  route    %-23T %v\n", boat.Route, stops(boat.Route))
		fmt.Fprintf(output, "  fees     %-23T %v\n", boat.Fees, boat.Fees)
		fmt.Fprintf(output, "  contact  %-23T %s, so by %s: %v\n", boat.Contact, boat.Contact, reached.Tag, reached.Value)
	}

	summaries, err := queries.BoatSummaries(ctx)
	if err != nil {
		return fmt.Errorf("summarising boats: %w", err)
	}
	fmt.Fprintln(output, "summaries:")
	for _, summary := range summaries {
		slices.Sort(summary.Months)
		augustFee := "none"
		if summary.AugustFee != nil {
			augustFee = fmt.Sprint(*summary.AugustFee)
		}
		fmt.Fprintf(output, "  %-10s skipper %s, crew of %d, fees for %v, August %s\n", summary.Name, *summary.Skipper, summary.CrewSize, summary.Months, augustFee)
	}
	return nil
}

// stops returns a route's stops, each as its latitude and longitude.
//
// Takes route ([]store.BoatsRouteItem) which is the route to describe.
//
// Returns []string which holds one latitude,longitude pair per stop.
func stops(route []store.BoatsRouteItem) []string {
	pairs := make([]string, 0, len(route))
	for _, stop := range route {
		pairs = append(pairs, nullable(stop.Lat)+","+nullable(stop.Lon))
	}
	return pairs
}

// nullable returns the value a nullable STRUCT field holds, or NULL when it holds none.
//
// Takes value (*T) which is the field.
//
// Returns string which is the value as text.
func nullable[T any](value *T) string {
	if value == nil {
		return "NULL"
	}
	return fmt.Sprint(*value)
}

main.go

// Command 037_duckdb_nested_types applies its migrations to an in-memory DuckDB database,
// then runs the example in example.go: boats in a marina are stored with STRUCT, MAP,
// LIST and UNION columns and read back as Go types.
//
// Run it with `go run .`, or `go run . -db example.duckdb` to keep the database in a
// file.
package main

import (
	"context"
	"database/sql"
	"embed"
	"flag"
	"fmt"
	"log"
	"os"

	_ "github.com/duckdb/duckdb-go/v2"

	"seagull.sh/seagull/sdk/migrate"
	"seagull.sh/seagull/sdk/migrate/migrate_backend_duckdb"
)

// migrations holds the migration files, compiled into the binary.
//
//go:embed db/migrations
var migrations embed.FS

func main() {
	path := flag.String("db", "", "DuckDB database file; an in-memory database when empty")
	flag.Parse()
	if err := execute(context.Background(), *path); err != nil {
		log.Fatal(err)
	}
}

// execute opens and migrates the DuckDB database at path, or an in-memory one when path
// is empty, and runs the example against it.
//
// Takes path (string) which is the database file, or empty.
//
// Returns error when the database cannot be prepared or the example fails.
func execute(ctx context.Context, path string) error {
	database, err := sql.Open("duckdb", path)
	if err != nil {
		return err
	}
	defer func() { _ = database.Close() }()

	migrator, err := migrate.NewMigrator(database, migrate_backend_duckdb.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/boats.sql.go

// Code generated by sgl. DO NOT EDIT.

package store

import "context"

const boatSummariesSQL = `SELECT
    name,
    crew[1] AS skipper,
    array_length(crew) AS crew_size,
    map_keys(fees) AS months,
    fees['August'] AS august_fee
FROM boats
ORDER BY id;`

type BoatSummariesRow struct {
	Name      string   `json:"name"`
	Skipper   *string  `json:"skipper"`
	CrewSize  int64    `json:"crew_size"`
	Months    []string `json:"months"`
	AugustFee *int32   `json:"august_fee"`
}

func (queries *Queries) BoatSummaries(ctx context.Context) ([]BoatSummariesRow, error) {
	rows, err := queries.reader.QueryContext(ctx, boatSummariesSQL)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var results []BoatSummariesRow
	for rows.Next() {
		var row BoatSummariesRow
		if err := rows.Scan(&row.Name, &row.Skipper, &row.CrewSize, sglScanDriverComposite(&row.Months), &row.AugustFee); err != nil {
			return nil, err
		}
		results = append(results, row)
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}
	return results, nil
}

const contactByPhoneSQL = `UPDATE boats SET contact = $1::VARCHAR WHERE name = $2;`

type ContactByPhoneParams struct {
	Phone string
	Name  string
}

func (queries *Queries) ContactByPhone(ctx context.Context, params ContactByPhoneParams) (int64, error) {
	results, err := queries.writer.ExecContext(ctx, contactByPhoneSQL, params.Phone, params.Name)
	if err != nil {
		return 0, err
	}
	return results.RowsAffected()
}

const contactByRadioSQL = `UPDATE boats SET contact = $1::INTEGER WHERE name = $2;`

type ContactByRadioParams struct {
	Channel int32
	Name    string
}

func (queries *Queries) ContactByRadio(ctx context.Context, params ContactByRadioParams) (int64, error) {
	results, err := queries.writer.ExecContext(ctx, contactByRadioSQL, params.Channel, params.Name)
	if err != nil {
		return 0, err
	}
	return results.RowsAffected()
}

const listBoatsSQL = `SELECT id, name, berth, crew, route, fees, contact FROM boats ORDER BY id;`

type ListBoatsRow struct {
	ID      int64            `json:"id"`
	Name    string           `json:"name"`
	Berth   BoatsBerth       `json:"berth"`
	Crew    []string         `json:"crew"`
	Route   []BoatsRouteItem `json:"route"`
	Fees    map[string]int32 `json:"fees"`
	Contact JSON             `json:"contact"`
}

func (queries *Queries) ListBoats(ctx context.Context) ([]ListBoatsRow, error) {
	rows, err := queries.reader.QueryContext(ctx, listBoatsSQL)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var results []ListBoatsRow
	for rows.Next() {
		var row ListBoatsRow
		if err := rows.Scan(&row.ID, &row.Name, sglScanDriverComposite(&row.Berth), sglScanDriverComposite(&row.Crew), sglScanDriverComposite(&row.Route), sglScanDriverComposite(&row.Fees), &row.Contact); err != nil {
			return nil, err
		}
		results = append(results, row)
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}
	return results, nil
}

const moorBoatSQL = `INSERT INTO boats (name, berth, crew, route, fees) VALUES ($1, $2, $3, $4, $5);`

type MoorBoatParams struct {
	Name  string
	Berth BoatsBerth
	Crew  []string
	Route []BoatsRouteItem
	Fees  map[string]int32
}

func (queries *Queries) MoorBoat(ctx context.Context, params MoorBoatParams) error {
	_, err := queries.writer.ExecContext(ctx, moorBoatSQL, params.Name, sglDriverComposite(params.Berth), params.Crew, sglDriverComposite(params.Route), sglDriverComposite(params.Fees))
	return err
}

internal/store/driver_composites.go

// Code generated by sgl. DO NOT EDIT.

package store

import (
	"database/sql"
	"database/sql/driver"
	"encoding"
	"encoding/json"
	"fmt"
	"math/big"
	"reflect"
	"strconv"
	"strings"

	"github.com/duckdb/duckdb-go/v2"
)

const sglMicrosecondsPerHour = 3_600_000_000

const sglMicrosecondsPerMinute = 60_000_000

const sglMicrosecondsPerSecond = 1_000_000

const sglMonthsPerYear = 12

const sglMinus = "-"

const sglDriverMaxDepth = 32

type sglDriverRefusal struct {
	err error
}

func (refusal sglDriverRefusal) Value() (driver.Value, error) {
	return nil, refusal.err
}

func sglDriverComposite(value any) any {
	converted, err := sglDriverConvert(value, 0)
	if err != nil {
		return sglDriverRefusal{err: err}
	}
	return converted
}

func sglDriverConvert(value any, depth int) (any, error) {
	if value == nil {
		return nil, nil
	}
	if depth > sglDriverMaxDepth {
		return nil, sglDriverTooDeep()
	}
	reflected := reflect.ValueOf(value)
	if reflected.Kind() == reflect.Pointer && reflected.IsNil() {
		return nil, nil
	}
	if valuer, ok := value.(driver.Valuer); ok {
		return valuer.Value()
	}
	if reflected.Kind() == reflect.Pointer {
		return sglDriverConvert(reflected.Elem().Interface(), depth+1)
	}
	if reflected.Type().PkgPath() == reflect.TypeFor[duckdb.Map]().PkgPath() {
		return value, nil
	}
	switch reflected.Kind() {
	case reflect.Map:
		return sglDriverCompositeMap(reflected, depth)
	case reflect.Slice:
		return sglDriverCompositeSlice(reflected, depth)
	case reflect.Struct:
		return sglDriverCompositeStruct(reflected, value, depth)
	default:
		return sglDriverBasic(reflected), nil
	}
}

func sglDriverTooDeep() error {
	return fmt.Errorf("sgl: a parameter nests more than %d levels deep or refers to itself", sglDriverMaxDepth)
}

func sglDriverBasic(reflected reflect.Value) any {
	if reflected.Type().PkgPath() == "" {
		return reflected.Interface()
	}
	switch reflected.Kind() {
	case reflect.String:
		return reflected.String()
	case reflect.Bool:
		return reflected.Bool()
	case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
		reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64,
		reflect.Float32, reflect.Float64:
		return reflected.Convert(sglDriverNumberType(reflected.Kind())).Interface()
	default:
		return reflected.Interface()
	}
}

func sglDriverNumberType(kind reflect.Kind) reflect.Type {
	switch kind {
	case reflect.Int:
		return reflect.TypeFor[int]()
	case reflect.Int8:
		return reflect.TypeFor[int8]()
	case reflect.Int16:
		return reflect.TypeFor[int16]()
	case reflect.Int32:
		return reflect.TypeFor[int32]()
	case reflect.Uint:
		return reflect.TypeFor[uint]()
	case reflect.Uint8:
		return reflect.TypeFor[uint8]()
	case reflect.Uint16:
		return reflect.TypeFor[uint16]()
	case reflect.Uint32:
		return reflect.TypeFor[uint32]()
	case reflect.Uint64:
		return reflect.TypeFor[uint64]()
	case reflect.Float32:
		return reflect.TypeFor[float32]()
	case reflect.Float64:
		return reflect.TypeFor[float64]()
	default:
		return reflect.TypeFor[int64]()
	}
}

func sglDriverCompositeMap(reflected reflect.Value, depth int) (any, error) {
	if reflected.IsNil() {
		return nil, nil
	}
	converted := make(duckdb.Map, reflected.Len())
	entries := reflected.MapRange()
	for entries.Next() {
		element, err := sglDriverConvert(entries.Value().Interface(), depth+1)
		if err != nil {
			return nil, err
		}
		converted[sglDriverBasic(entries.Key())] = element
	}
	return converted, nil
}

func sglDriverCompositeSlice(reflected reflect.Value, depth int) (any, error) {
	if reflected.IsNil() {
		return nil, nil
	}
	if reflected.Type().Elem().Kind() == reflect.Uint8 {
		return reflected.Bytes(), nil
	}
	converted := make([]any, reflected.Len())
	for index := range reflected.Len() {
		element, err := sglDriverConvert(reflected.Index(index).Interface(), depth+1)
		if err != nil {
			return nil, err
		}
		converted[index] = element
	}
	return converted, nil
}

func sglDriverCompositeStruct(reflected reflect.Value, value any, depth int) (any, error) {
	structType := reflected.Type()
	fields := make(map[string]any, structType.NumField())
	for index := range structType.NumField() {
		field := structType.Field(index)
		if !field.IsExported() {
			return value, nil
		}
		name, _, _ := strings.Cut(field.Tag.Get("json"), ",")
		if name == "-" {
			continue
		}
		if name == "" {
			name = field.Name
		}
		element, err := sglDriverConvert(reflected.Field(index).Interface(), depth+1)
		if err != nil {
			return nil, err
		}
		fields[name] = element
	}
	return fields, nil
}

func sglScanDriverComposite[T any](dest *T) sql.Scanner {
	return sglDriverCompositeScanner[T]{dest: dest}
}

type sglDriverCompositeScanner[T any] struct {
	dest *T
}

func (scanner sglDriverCompositeScanner[T]) Scan(source any) error {
	if err := sglReadDriverValue(reflect.ValueOf(scanner.dest).Elem(), source); err != nil {
		return fmt.Errorf("sgl: read %T into %T: %w", source, *scanner.dest, err)
	}
	return nil
}

func sglReadDriverValue(target reflect.Value, source any) error {
	if source == nil {
		return sglReadDriverNull(target)
	}
	value := reflect.ValueOf(source)
	if value.Type().AssignableTo(target.Type()) {
		target.Set(value)
		return nil
	}
	if target.Kind() == reflect.Pointer {
		element := reflect.New(target.Type().Elem())
		if err := sglReadDriverValue(element.Elem(), source); err != nil {
			return err
		}
		target.Set(element)
		return nil
	}
	if read, err := sglReadDriverLeaf(target, source); read {
		return err
	}
	return sglReadDriverShape(target, value)
}

func sglReadDriverNull(target reflect.Value) error {
	switch target.Kind() {
	case reflect.Pointer, reflect.Slice, reflect.Map, reflect.Interface:
		target.SetZero()
		return nil
	}
	if scanner, scans := reflect.TypeAssert[sql.Scanner](target.Addr()); scans {
		return scanner.Scan(nil)
	}
	return fmt.Errorf("converting NULL to %s is unsupported", target.Type())
}

func sglReadDriverShape(target reflect.Value, value reflect.Value) error {
	switch target.Kind() {
	case reflect.Slice:
		return sglReadList(target, value, sglReadDriverValue)
	case reflect.Map:
		return sglReadMap(target, value, sglReadDriverValue)
	case reflect.Struct:
		return sglReadDriverStruct(target, value)
	default:
		return sglReadScalar(target, value)
	}
}

func sglReadDriverLeaf(target reflect.Value, source any) (bool, error) {
	switch typed := source.(type) {
	case duckdb.Decimal:
		return true, sglReadDriverDecimal(target, typed)
	case duckdb.Interval:
		if target.Kind() == reflect.String {
			target.SetString(sglDriverIntervalText(typed))
			return true, nil
		}
		return true, sglUnreadable(reflect.TypeOf(source), target.Type())
	}
	address := target.Addr().Interface()
	if unmarshaler, ok := address.(json.Unmarshaler); ok {
		encoded, err := json.Marshal(source)
		if err != nil {
			return true, err
		}
		return true, unmarshaler.UnmarshalJSON(encoded)
	}
	if scanner, ok := address.(sql.Scanner); ok {
		return true, scanner.Scan(source)
	}
	return false, nil
}

func sglReadDriverDecimal(target reflect.Value, number duckdb.Decimal) error {
	text := sglDriverDecimalText(number)
	address := target.Addr().Interface()
	if unmarshaler, ok := address.(encoding.TextUnmarshaler); ok {
		return unmarshaler.UnmarshalText([]byte(text))
	}
	switch target.Kind() {
	case reflect.String:
		target.SetString(text)
		return nil
	case reflect.Float32, reflect.Float64:
		parsed, err := strconv.ParseFloat(text, target.Type().Bits())
		if err != nil {
			return err
		}
		target.SetFloat(parsed)
		return nil
	default:
		return fmt.Errorf("cannot read a decimal into %s", target.Type())
	}
}

func sglDriverDecimalText(number duckdb.Decimal) string {
	if number.Value == nil {
		return "0"
	}
	digits := new(big.Int).Abs(number.Value).String()
	scale := int(number.Scale)
	if scale == 0 {
		return number.Value.String()
	}
	if len(digits) <= scale {
		digits = strings.Repeat("0", scale-len(digits)+1) + digits
	}
	sign := ""
	if number.Value.Sign() < 0 {
		sign = sglMinus
	}
	return sign + digits[:len(digits)-scale] + "." + digits[len(digits)-scale:]
}

func sglReadDriverStruct(target reflect.Value, value reflect.Value) error {
	source := value.Interface()
	fields, ok := source.(map[string]any)
	if !ok {
		return sglUnreadable(value.Type(), target.Type())
	}
	target.SetZero()
	structType := target.Type()
	for index := range structType.NumField() {
		name, readable := sglDriverFieldName(structType.Field(index))
		if !readable {
			continue
		}
		element, found := sglDriverField(fields, name)
		if !found {
			continue
		}
		if err := sglReadDriverValue(target.Field(index), element); err != nil {
			return fmt.Errorf("field %s: %w", name, err)
		}
	}
	return nil
}

func sglDriverFieldName(field reflect.StructField) (string, bool) {
	if !field.IsExported() {
		return "", false
	}
	name, _, _ := strings.Cut(field.Tag.Get("json"), ",")
	switch name {
	case "-":
		return "", false
	case "":
		return field.Name, true
	default:
		return name, true
	}
}

func sglDriverField(fields map[string]any, name string) (any, bool) {
	if element, found := fields[name]; found {
		return element, true
	}
	for key, element := range fields {
		if strings.EqualFold(key, name) {
			return element, true
		}
	}
	return nil, false
}

func sglDriverIntervalText(interval duckdb.Interval) string {
	var parts []string
	parts = sglDriverIntervalPart(parts, int64(interval.Months/sglMonthsPerYear), "year")
	parts = sglDriverIntervalPart(parts, int64(interval.Months%sglMonthsPerYear), "month")
	parts = sglDriverIntervalPart(parts, int64(interval.Days), "day")
	if interval.Micros != 0 {
		parts = append(parts, sglDriverIntervalClock(interval.Micros))
	}
	if len(parts) == 0 {
		return "00:00:00"
	}
	return strings.Join(parts, " ")
}

func sglDriverIntervalPart(parts []string, count int64, unit string) []string {
	if count == 0 {
		return parts
	}
	if count != 1 && count != -1 {
		unit += "s"
	}
	return append(parts, strconv.FormatInt(count, 10)+" "+unit)
}

func sglDriverIntervalClock(micros int64) string {
	sign := ""
	remainder := micros
	if micros < 0 {
		sign = sglMinus
	} else {
		remainder = -micros
	}
	hours := -(remainder / sglMicrosecondsPerHour)
	remainder += hours * sglMicrosecondsPerHour
	minutes := -(remainder / sglMicrosecondsPerMinute)
	remainder += minutes * sglMicrosecondsPerMinute
	seconds := -(remainder / sglMicrosecondsPerSecond)
	clock := fmt.Sprintf("%s%02d:%02d:%02d", sign, hours, minutes, seconds)
	if fraction := -(remainder + seconds*sglMicrosecondsPerSecond); fraction != 0 {
		clock += "." + strings.TrimRight(fmt.Sprintf("%06d", fraction), "0")
	}
	return clock
}

internal/store/models.go

// Code generated by sgl. DO NOT EDIT.

package store

type Boats struct {
	ID      int64            `json:"id"`
	Name    string           `json:"name"`
	Berth   BoatsBerth       `json:"berth"`
	Crew    []string         `json:"crew"`
	Route   []BoatsRouteItem `json:"route"`
	Fees    map[string]int32 `json:"fees"`
	Contact JSON             `json:"contact"`
}

type BoatsBerth struct {
	Pontoon *string `json:"pontoon"`
	Number  *int32  `json:"number"`
}

type BoatsRouteItem struct {
	Lat *float64 `json:"lat"`
	Lon *float64 `json:"lon"`
}

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 (
	"database/sql/driver"
	"encoding/json"
	"fmt"
)

type JSON []byte

func (j *JSON) Scan(source any) error {
	switch value := source.(type) {
	case nil:
		*j = nil
		return nil
	case []byte:
		clone := make(JSON, len(value))
		copy(clone, value)
		*j = clone
		return nil
	case string:
		*j = JSON(value)
		return nil
	default:
		encoded, err := json.Marshal(value)
		if err != nil {
			return fmt.Errorf("sgl: encode %T to JSON: %w", source, err)
		}
		*j = encoded
		return nil
	}
}

func (j JSON) Value() (driver.Value, error) {
	if j == nil {
		return nil, nil
	}
	return string(j), nil
}

func (j JSON) MarshalJSON() ([]byte, error) {
	if j == nil {
		return []byte("null"), nil
	}
	return j, nil
}

func (j *JSON) UnmarshalJSON(data []byte) error {
	clone := make(JSON, len(data))
	copy(clone, data)
	*j = clone
	return nil
}

internal/store/value_reading.go

// Code generated by sgl. DO NOT EDIT.

package store

import (
	"fmt"
	"reflect"
)

type sglOrderedMap interface {
	Keys() []any
	Values() []any
}

func sglReadList(target reflect.Value, value reflect.Value, read func(reflect.Value, any) error) error {
	if value.Kind() != reflect.Slice && value.Kind() != reflect.Array {
		return sglUnreadable(value.Type(), target.Type())
	}
	if value.Kind() == reflect.Slice && value.IsNil() {
		target.SetZero()
		return nil
	}
	list := reflect.MakeSlice(target.Type(), value.Len(), value.Len())
	for index := range value.Len() {
		if err := read(list.Index(index), value.Index(index).Interface()); err != nil {
			return fmt.Errorf("element %d: %w", index, err)
		}
	}
	target.Set(list)
	return nil
}

func sglReadMap(target reflect.Value, value reflect.Value, read func(reflect.Value, any) error) error {
	if value.Kind() == reflect.Map && value.IsNil() {
		target.SetZero()
		return nil
	}
	keys, values, ok := sglMapEntries(value)
	if !ok {
		return sglUnreadable(value.Type(), target.Type())
	}
	converted := reflect.MakeMapWithSize(target.Type(), len(keys))
	for index, key := range keys {
		convertedKey := reflect.New(target.Type().Key()).Elem()
		if err := read(convertedKey, key); err != nil {
			return fmt.Errorf("key %v: %w", key, err)
		}
		element := reflect.New(target.Type().Elem()).Elem()
		if err := read(element, values[index]); err != nil {
			return fmt.Errorf("value of key %v: %w", key, err)
		}
		converted.SetMapIndex(convertedKey, element)
	}
	target.Set(converted)
	return nil
}

func sglMapEntries(value reflect.Value) (keys []any, values []any, ok bool) {
	if value.Kind() == reflect.Map {
		keys = make([]any, 0, value.Len())
		values = make([]any, 0, value.Len())
		entries := value.MapRange()
		for entries.Next() {
			keys = append(keys, entries.Key().Interface())
			values = append(values, entries.Value().Interface())
		}
		return keys, values, true
	}
	pointer := reflect.New(value.Type())
	pointer.Elem().Set(value)
	ordered, ordersEntries := reflect.TypeAssert[sglOrderedMap](pointer)
	if !ordersEntries {
		return nil, nil, false
	}
	keys, values = ordered.Keys(), ordered.Values()
	return keys, values, len(keys) == len(values)
}

func sglReadScalar(target reflect.Value, value reflect.Value) error {
	kind := sglScalarKind(value.Kind())
	if kind == "" || kind != sglScalarKind(target.Kind()) || !value.CanConvert(target.Type()) {
		return sglUnreadable(value.Type(), target.Type())
	}
	converted := value.Convert(target.Type())
	if !sglScalarHolds(converted, value) {
		return fmt.Errorf("%v overflows %s", value.Interface(), target.Type())
	}
	target.Set(converted)
	return nil
}

func sglScalarHolds(converted reflect.Value, value reflect.Value) bool {
	switch {
	case !value.CanInt() && !value.CanUint():
		return true
	case !converted.Convert(value.Type()).Equal(value):
		return false
	case value.CanInt() && converted.CanUint():
		return value.Int() >= 0
	case value.CanUint() && converted.CanInt():
		return converted.Int() >= 0
	default:
		return true
	}
}

func sglScalarKind(kind reflect.Kind) string {
	switch kind {
	case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
		reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
		return "integer"
	case reflect.Float32, reflect.Float64:
		return "float"
	case reflect.String:
		return "string"
	case reflect.Bool:
		return "boolean"
	default:
		return ""
	}
}

func sglUnreadable(source reflect.Type, target reflect.Type) error {
	return fmt.Errorf("cannot read %s into %s", source, target)
}

output.txt

Kittiwake
  berth    store.BoatsBerth        pontoon A, berth 12
  crew     []string                [Morwenna Jago]
  route    []store.BoatsRouteItem  [50.1,-5.54 50.08,-5.55]
  fees     map[string]int32        map[August:450 July:300]
  contact  store.JSON              {"value":"07700 900123","tag":"phone"}, so by phone: 07700 900123
Salty Chip
  berth    store.BoatsBerth        pontoon B, berth 3
  crew     []string                [Gwen]
  route    []store.BoatsRouteItem  []
  fees     map[string]int32        map[July:180]
  contact  store.JSON              {"value":16,"tag":"vhf_channel"}, so by vhf_channel: 16
summaries:
  Kittiwake  skipper Morwenna, crew of 2, fees for [August July], August 450
  Salty Chip skipper Gwen, crew of 1, fees for [July], August none