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
LISTof a scalar, such ascrew VARCHAR[], is a typed slice,[]string. - A
STRUCT,berth, is a Go struct with a field for each of its fields,Pontoon stringandNumber int32, tagged with the field's DuckDB name. - A
LISTofSTRUCT,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 generatedJSONtype, holding the member's tag and value as{"value": ..., "tag": ...}. The column may be NULL, and a nilJSONis 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 anint64.map_keys(fees)is a[]stringof 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