From SQL types to Go types
On this page
On ClickHouse:
- Integer widths and signs are exact: UInt16, UInt32 and UInt64 map to uint16, uint32 and uint64, Int64 to int64
- Float32 maps to float32, keeping its width like the integers, and Bool maps to bool
- UUID maps to uuid.UUID (github.com/google/uuid) and Decimal(P, S) to decimal.Decimal (github.com/shopspring/decimal), the types clickhouse-go scans into
- The example passes the UUID and decimal as String and converts them with toUUID and toDecimal32, so example.go needs only the standard library
- DateTime('UTC') maps to time.Time; a time parameter is sent as Unix seconds, so its moment is kept whatever the server's zone, and the column's zone makes it read back in UTC
- No BLOB: String and FixedString(N) hold any bytes and map to string
- Nullable(String) maps to *string; every other column is NOT NULL
Seagull reads each column's declared type from your migrations and picks the Go type that holds it without loss. Integers keep the width and sign the engine stores, floats their precision, exact decimals become a decimal type rather than a float, dates and times become time.Time, and a column that can be NULL becomes a pointer. You never choose a Go type for a column by hand, and you never scan a NUMERIC into a float64 by accident.
This example records one landing of fish, with a column of each common kind, reads it back, and prints the Go type every column became.
The schema
db/migrations/0001_landings.up.sql
CREATE TABLE landings (
id UInt64,
catch_id UUID,
species String,
boxes UInt16,
fish UInt32,
weight_g Int64,
length_cm Float32,
price Decimal(9, 2),
on_ice Bool,
landed_at DateTime('UTC'),
tag FixedString(4),
notes Nullable(String)
) ENGINE = MergeTree() ORDER BY id;
What sgl generates
The row type for GetLanding is the mapping, field by field:
internal/store
type GetLandingRow struct {
ID uint64 `json:"id"`
CatchID uuid.UUID `json:"catch_id"`
Species string `json:"species"`
Boxes uint16 `json:"boxes"`
Fish uint32 `json:"fish"`
WeightG int64 `json:"weight_g"`
LengthCm float32 `json:"length_cm"`
Price decimal.Decimal `json:"price"`
OnIce bool `json:"on_ice"`
LandedAt time.Time `json:"landed_at"`
Tag string `json:"tag"`
Notes *string `json:"notes"`
}
func (queries *Queries) GetLanding(ctx context.Context, id uint64) (GetLandingRow, error) {
var row GetLandingRow
err := queries.reader.QueryRowContext(ctx, getLandingSQL, clickhouse.Named("p_id", sglClickHouseShapedValue(id, sglClickHouseShapes[0]))).Scan(&row.ID, &row.CatchID, &row.Species, &row.Boxes, &row.Fish, &row.WeightG, &row.LengthCm, &row.Price, &row.OnIce, &row.LandedAt, &row.Tag, &row.Notes)
if err != nil {
return GetLandingRow{}, err
}
return row, nil
}
- ClickHouse is exact about width and sign, and so is the mapping:
UInt16,UInt32andUInt64giveuint16,uint32anduint64, andInt64givesint64. Float32givesfloat32, keeping its width like the integers, andBoolgivesbool.UUIDgivesuuid.UUIDfromgithub.com/google/uuid, andDecimal(9, 2)givesdecimal.Decimalfromgithub.com/shopspring/decimal: the types clickhouse-go scans them into.DateTime('UTC')givestime.Time.- There is no
BLOB.StringandFixedString(N)hold any bytes, and both givestring. Nullable(String)gives*string. Every other column isNOT NULL, so nothing else is a pointer.
Parameters follow the same rules, so RecordLanding takes the same Go types it hands back.
The exceptions here are deliberate. The insert passes the catch id and the price as String placeholders and converts them in SQL with toUUID and toDecimal32, so the program needs only the standard library to write them:
db/queries/landings.sql
-- sgl.query(RecordLanding, exec)
INSERT INTO landings (id, catch_id, species, boxes, fish, weight_g, length_cm, price, on_ice, landed_at, tag, notes)
VALUES (
{id:UInt64},
toUUID({catch_id:String}),
{species:String},
{boxes:UInt16},
{fish:UInt32},
{weight_g:Int64},
{length_cm:Float32},
toDecimal32({price:String}, 2),
{on_ice:Bool},
{landed_at:DateTime('UTC')},
{tag:FixedString(4)},
{notes:Nullable(String)}
);
Using it
The program records a landing and reads it back, then prints each column's name, the Go type of its field (with %T) and its value.
example.go
package main
import (
"context"
"database/sql"
"fmt"
"io"
"time"
"seagull.sh/seagull/examples/clickhouse/005_type_mapping/internal/store"
)
// run records a landing of fish and reads it back, printing the Go type sgl chose for
// each column. ClickHouse types are exact about width and sign, and Seagull follows them:
// UInt16, UInt32 and UInt64 give uint16, uint32 and uint64, Int64 gives int64, Float32
// gives float32, and Bool gives bool. DateTime('UTC') gives time.Time; the generated code
// sends a time as Unix seconds, so the server keeps its moment whatever the zone, and the
// column's time zone makes it read back in UTC. FixedString(4) and String both give
// string, which in ClickHouse holds any bytes, and Nullable(String) gives *string.
//
// UUID gives uuid.UUID from github.com/google/uuid and Decimal(9, 2) gives
// decimal.Decimal from github.com/shopspring/decimal, the types clickhouse-go scans them
// into. A {price:Decimal(9, 2)} placeholder would make the parameter a decimal.Decimal
// too; this example passes the price and catch id as text and converts them in SQL with
// toDecimal32 and toUUID, so it needs nothing beyond the standard library.
//
// Takes database (*sql.DB) which is a migrated database.
// Takes output (io.Writer) which receives one line per column.
//
// Returns error when a query fails.
func run(ctx context.Context, database *sql.DB, output io.Writer) error {
queries := store.New(database)
if err := queries.RecordLanding(ctx, store.RecordLandingParams{
ID: 1,
CatchID: "5f0c6c7e-8d0e-4b77-9a51-2c4a6f9e3b10",
Species: "mackerel",
Boxes: 12,
Fish: 480,
WeightG: 163_200,
LengthCm: 31.5,
Price: "412.50",
OnIce: true,
LandedAt: time.Date(2026, time.May, 1, 5, 30, 0, 0, time.UTC),
Tag: string([]byte{0x04, 0xa2, 0x19, 0xf3}),
Notes: new("landed at Newlyn"),
}); err != nil {
return fmt.Errorf("recording a landing: %w", err)
}
landing, err := queries.GetLanding(ctx, 1)
if err != nil {
return fmt.Errorf("getting landing 1: %w", err)
}
fmt.Fprintf(output, "id %-16T %d\n", landing.ID, landing.ID)
fmt.Fprintf(output, "catch_id %-16T %s\n", landing.CatchID, landing.CatchID)
fmt.Fprintf(output, "species %-16T %s\n", landing.Species, landing.Species)
fmt.Fprintf(output, "boxes %-16T %d\n", landing.Boxes, landing.Boxes)
fmt.Fprintf(output, "fish %-16T %d\n", landing.Fish, landing.Fish)
fmt.Fprintf(output, "weight_g %-16T %d\n", landing.WeightG, landing.WeightG)
fmt.Fprintf(output, "length_cm %-16T %.1f\n", landing.LengthCm, landing.LengthCm)
fmt.Fprintf(output, "price %-16T %s\n", landing.Price, landing.Price.StringFixed(2))
fmt.Fprintf(output, "on_ice %-16T %t\n", landing.OnIce, landing.OnIce)
fmt.Fprintf(output, "landed_at %-16T %s\n", landing.LandedAt, landing.LandedAt.Format(time.RFC3339))
fmt.Fprintf(output, "tag %-16T % x\n", landing.Tag, landing.Tag)
fmt.Fprintf(output, "notes %-16T %s\n", landing.Notes, *landing.Notes)
return nil
}
What it prints
id uint64 1
catch_id uuid.UUID 5f0c6c7e-8d0e-4b77-9a51-2c4a6f9e3b10
species string mackerel
boxes uint16 12
fish uint32 480
weight_g int64 163200
length_cm float32 31.5
price decimal.Decimal 412.50
on_ice bool true
landed_at time.Time 2026-05-01T05:30:00Z
tag string 04 a2 19 f3
notes *string landed at Newlyn
Changing a type
The defaults are a starting point. A go_type directive on a column, or an override in sgl.yaml, swaps in a type of your own, such as a named Pence type for money; Type overrides shows how. The type mapping reference lists every default, and the order in which overrides apply.
Across engines
Only these Go types of GetLanding differ between engines; the rest are the same everywhere.
| ClickHouse | SQLite | PostgreSQL | MySQL | DuckDB | |
|---|---|---|---|---|---|
id parameter | uint64 | int64 | int64 | int64 | int64 |
id column | uint64 | int64 | int64 | int64 | int64 |
boxes column | uint16 | int64 | int16 | int16 | int16 |
fish column | uint32 | int64 | int32 | int32 | int32 |
length_cm column | float32 | float64 | float32 | float64 | float64 |
tag column | string | []byte | []byte | []byte | []byte |
catch_id column | uuid.UUID | · | uuid.UUID | · | uuid.UUID |
sea_temp column | · | · | float64 | · | · |
price column | decimal.Decimal | · | apd.Decimal | · | · |
landed_on column | · | · | time.Time | · | time.Time |
harbour_ip column | · | · | any | · | · |
berth column | · | · | · | uint8 | · |
price_per_kg column | · | · | · | apd.Decimal | · |
quay column | · | · | · | · | uint8 |
water_temp column | · | · | · | · | float32 |
auction_lot column | · | · | · | · | *big.Int |
A · marks a parameter or column that version of the example does not have.
TimescaleDB runs the PostgreSQL version unchanged.
MariaDB runs the MySQL version unchanged.
Compare the whole example side by side with SQLite, PostgreSQL, MySQL and DuckDB.
Run it locally
From the directory you cloned the Seagull repository into:
cd seagull/examples/clickhouse && go run ./005_type_mapping
Its test runs it too, and checks what it prints against output.txt:
cd seagull/examples/clickhouse && SGL_EXAMPLE_DSN=clickhouse://localhost:9000/default go test ./005_type_mapping
Needs a running ClickHouse server: it connects to clickhouse://localhost:9000/default, or pass -dsn with 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: landings
dialect: clickhouse
migrations: db/migrations
queries: db/queries
go:
package: store
out: internal/store
db/migrations/0001_landings.up.sql
CREATE TABLE landings (
id UInt64,
catch_id UUID,
species String,
boxes UInt16,
fish UInt32,
weight_g Int64,
length_cm Float32,
price Decimal(9, 2),
on_ice Bool,
landed_at DateTime('UTC'),
tag FixedString(4),
notes Nullable(String)
) ENGINE = MergeTree() ORDER BY id;
db/migrations/0001_landings.down.sql
DROP TABLE landings;
db/queries/landings.sql
-- sgl.query(RecordLanding, exec)
INSERT INTO landings (id, catch_id, species, boxes, fish, weight_g, length_cm, price, on_ice, landed_at, tag, notes)
VALUES (
{id:UInt64},
toUUID({catch_id:String}),
{species:String},
{boxes:UInt16},
{fish:UInt32},
{weight_g:Int64},
{length_cm:Float32},
toDecimal32({price:String}, 2),
{on_ice:Bool},
{landed_at:DateTime('UTC')},
{tag:FixedString(4)},
{notes:Nullable(String)}
);
-- sgl.query(GetLanding, one)
SELECT id, catch_id, species, boxes, fish, weight_g, length_cm, price, on_ice, landed_at, tag, notes
FROM landings
WHERE id = {id:UInt64};
example.go
package main
import (
"context"
"database/sql"
"fmt"
"io"
"time"
"seagull.sh/seagull/examples/clickhouse/005_type_mapping/internal/store"
)
// run records a landing of fish and reads it back, printing the Go type sgl chose for
// each column. ClickHouse types are exact about width and sign, and Seagull follows them:
// UInt16, UInt32 and UInt64 give uint16, uint32 and uint64, Int64 gives int64, Float32
// gives float32, and Bool gives bool. DateTime('UTC') gives time.Time; the generated code
// sends a time as Unix seconds, so the server keeps its moment whatever the zone, and the
// column's time zone makes it read back in UTC. FixedString(4) and String both give
// string, which in ClickHouse holds any bytes, and Nullable(String) gives *string.
//
// UUID gives uuid.UUID from github.com/google/uuid and Decimal(9, 2) gives
// decimal.Decimal from github.com/shopspring/decimal, the types clickhouse-go scans them
// into. A {price:Decimal(9, 2)} placeholder would make the parameter a decimal.Decimal
// too; this example passes the price and catch id as text and converts them in SQL with
// toDecimal32 and toUUID, so it needs nothing beyond the standard library.
//
// Takes database (*sql.DB) which is a migrated database.
// Takes output (io.Writer) which receives one line per column.
//
// Returns error when a query fails.
func run(ctx context.Context, database *sql.DB, output io.Writer) error {
queries := store.New(database)
if err := queries.RecordLanding(ctx, store.RecordLandingParams{
ID: 1,
CatchID: "5f0c6c7e-8d0e-4b77-9a51-2c4a6f9e3b10",
Species: "mackerel",
Boxes: 12,
Fish: 480,
WeightG: 163_200,
LengthCm: 31.5,
Price: "412.50",
OnIce: true,
LandedAt: time.Date(2026, time.May, 1, 5, 30, 0, 0, time.UTC),
Tag: string([]byte{0x04, 0xa2, 0x19, 0xf3}),
Notes: new("landed at Newlyn"),
}); err != nil {
return fmt.Errorf("recording a landing: %w", err)
}
landing, err := queries.GetLanding(ctx, 1)
if err != nil {
return fmt.Errorf("getting landing 1: %w", err)
}
fmt.Fprintf(output, "id %-16T %d\n", landing.ID, landing.ID)
fmt.Fprintf(output, "catch_id %-16T %s\n", landing.CatchID, landing.CatchID)
fmt.Fprintf(output, "species %-16T %s\n", landing.Species, landing.Species)
fmt.Fprintf(output, "boxes %-16T %d\n", landing.Boxes, landing.Boxes)
fmt.Fprintf(output, "fish %-16T %d\n", landing.Fish, landing.Fish)
fmt.Fprintf(output, "weight_g %-16T %d\n", landing.WeightG, landing.WeightG)
fmt.Fprintf(output, "length_cm %-16T %.1f\n", landing.LengthCm, landing.LengthCm)
fmt.Fprintf(output, "price %-16T %s\n", landing.Price, landing.Price.StringFixed(2))
fmt.Fprintf(output, "on_ice %-16T %t\n", landing.OnIce, landing.OnIce)
fmt.Fprintf(output, "landed_at %-16T %s\n", landing.LandedAt, landing.LandedAt.Format(time.RFC3339))
fmt.Fprintf(output, "tag %-16T % x\n", landing.Tag, landing.Tag)
fmt.Fprintf(output, "notes %-16T %s\n", landing.Notes, *landing.Notes)
return nil
}
main.go
// Command 005_type_mapping applies its migrations to a ClickHouse database, then runs the
// example in example.go: one landing of fish is recorded and read back, printing each
// column's Go type and value.
//
// Run it with `go run .` against a local server, or `go run . -dsn <dsn>` to choose the
// database; give it an empty one.
package main
import (
"context"
"database/sql"
"embed"
"flag"
"fmt"
"log"
"os"
_ "github.com/ClickHouse/clickhouse-go/v2"
"seagull.sh/seagull/sdk/migrate"
"seagull.sh/seagull/sdk/migrate/migrate_backend_clickhouse"
)
// migrations holds the migration files, compiled into the binary.
//
//go:embed db/migrations
var migrations embed.FS
func main() {
dsn := flag.String("dsn", "clickhouse://localhost:9000/default", "ClickHouse DSN of an empty database")
flag.Parse()
if err := execute(context.Background(), *dsn); err != nil {
log.Fatal(err)
}
}
// execute opens and migrates the database the DSN names, and runs the example against it.
//
// Takes dsn (string) which names the ClickHouse database.
//
// Returns error when the database cannot be prepared or the example fails.
func execute(ctx context.Context, dsn string) error {
database, err := sql.Open("clickhouse", dsn)
if err != nil {
return err
}
defer func() { _ = database.Close() }()
migrator, err := migrate.NewMigrator(database, migrate_backend_clickhouse.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/clickhouse_values.go
// Code generated by sgl. DO NOT EDIT.
package store
import (
"errors"
"fmt"
"math"
"net"
"net/netip"
"reflect"
"slices"
"strconv"
"strings"
"time"
)
const (
sglClickHouseMaxDepth = 32
sglClickHouseNanosecondDigits = 9
sglClickHouseDigitGroup = 3
)
var (
sglClickHouseTextEscaper = strings.NewReplacer("\\", "\\\\", "\t", "\\t", "\n", "\\n", "\r", "\\r", "\x00", "\\0")
sglClickHouseQuotedEscaper = strings.NewReplacer("\\", "\\\\", "'", "\\'", "\t", "\\t", "\n", "\\n", "\r", "\\r", "\x00", "\\0")
sglClickHouseRefusalCleaner = strings.NewReplacer("\t", " ", "\n", " ", "\r", " ")
errSglClickHouseShape = errors.New("sgl: malformed ClickHouse parameter shape")
errSglClickHouseValue = errors.New("sgl: cannot write the value of a ClickHouse parameter")
)
type sglClickHouseShape struct {
names []string
elements []sglClickHouseShape
digits int
prepared bool
checked bool
driverMap bool
kind byte
}
var sglClickHouseShapes = []sglClickHouseShape{{kind: 110, digits: -1, prepared: true, checked: false, driverMap: false, names: []string{}, elements: []sglClickHouseShape{}}, {kind: 113, digits: -1, prepared: true, checked: false, driverMap: false, names: []string{}, elements: []sglClickHouseShape{}}, {kind: 116, digits: 0, prepared: true, checked: false, driverMap: false, names: []string{}, elements: []sglClickHouseShape{}}}
func sglClickHouseValue(value any, shape string) string {
parsed, err := sglClickHouseParseShape(shape)
if err == nil {
return sglClickHouseShapedValue(value, parsed)
}
return sglClickHouseRefusalCleaner.Replace(err.Error()) + "\t"
}
func sglClickHouseShapedValue(value any, shape sglClickHouseShape) string {
text, err := sglClickHouseWhole(reflect.ValueOf(value), shape)
if err != nil {
return sglClickHouseRefusalCleaner.Replace(err.Error()) + "\t"
}
return text
}
func sglClickHouseWhole(value reflect.Value, shape sglClickHouseShape) (string, error) {
current, err := sglClickHouseFollow(value, 0)
if err != nil {
return "", err
}
if !current.IsValid() {
return "\\N", nil
}
shape = sglClickHouseResolve(current, shape)
switch shape.kind {
case '[', '{', '(':
return sglClickHouseComposite(current, shape, 0)
}
text, err := sglClickHouseScalar(current, shape)
if err != nil {
return "", err
}
return sglClickHouseTextEscaper.Replace(text), nil
}
func sglClickHouseAppendElement(text []byte, value reflect.Value, shape sglClickHouseShape, depth int) ([]byte, error) {
current, err := sglClickHouseFollow(value, depth)
if err != nil {
return nil, err
}
if !current.IsValid() {
return append(text, "NULL"...), nil
}
shape = sglClickHouseResolve(current, shape)
switch shape.kind {
case '[', '{', '(':
return sglClickHouseAppendComposite(text, current, shape, depth)
}
if shape.kind == 'n' && sglClickHouseIsNumber(current) {
return sglClickHouseAppendNumber(text, current), nil
}
scalar, err := sglClickHouseScalar(current, shape)
if err != nil {
return nil, err
}
if shape.kind == 'n' {
return append(text, scalar...), nil
}
text = append(text, '\'')
text = append(text, sglClickHouseQuotedEscaper.Replace(scalar)...)
return append(text, '\''), nil
}
func sglClickHouseAppendNumber(text []byte, value reflect.Value) []byte {
const decimalBase = 10
switch value.Kind() {
case reflect.Bool:
return strconv.AppendBool(text, value.Bool())
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return strconv.AppendInt(text, value.Int(), decimalBase)
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
return strconv.AppendUint(text, value.Uint(), decimalBase)
}
number := value.Float()
switch {
case math.IsNaN(number):
return append(text, "nan"...)
case math.IsInf(number, 1):
return append(text, "inf"...)
case math.IsInf(number, -1):
return append(text, "-inf"...)
}
return strconv.AppendFloat(text, number, 'g', -1, value.Type().Bits())
}
func sglClickHouseFollow(value reflect.Value, depth int) (reflect.Value, error) {
for value.IsValid() && (value.Kind() == reflect.Pointer || value.Kind() == reflect.Interface) {
if value.IsNil() {
return reflect.Value{}, nil
}
depth++
if depth > sglClickHouseMaxDepth {
return reflect.Value{}, fmt.Errorf("%w: it nests more than %d levels deep or refers to itself", errSglClickHouseValue, sglClickHouseMaxDepth)
}
value = value.Elem()
}
return value, nil
}
func sglClickHouseResolve(value reflect.Value, shape sglClickHouseShape) sglClickHouseShape {
if shape.kind != 'a' {
return shape
}
unknown := sglClickHouseShape{names: nil, elements: nil, digits: -1, kind: 'a', checked: false, driverMap: false, prepared: false}
if _, moment := sglClickHouseMoment(value); moment {
return sglClickHouseShape{names: nil, elements: nil, digits: -1, kind: 't', checked: false, driverMap: false, prepared: false}
}
if sglClickHouseIsNumber(value) {
return sglClickHouseShape{names: nil, elements: nil, digits: -1, kind: 'n', checked: false, driverMap: false, prepared: false}
}
if sglClickHouseHasText(value) {
return sglClickHouseShape{names: nil, elements: nil, digits: -1, kind: 'q', checked: false, driverMap: false, prepared: false}
}
switch value.Kind() {
case reflect.Slice, reflect.Array:
if value.Type().Elem().Kind() != reflect.Uint8 {
return sglClickHouseShape{names: nil, elements: []sglClickHouseShape{unknown}, digits: -1, kind: '[', checked: false, driverMap: false, prepared: false}
}
case reflect.Map:
return sglClickHouseShape{names: nil, elements: []sglClickHouseShape{unknown, unknown}, digits: -1, kind: '{', checked: false, driverMap: false, prepared: false}
case reflect.Struct:
fields := make([]sglClickHouseShape, value.NumField())
for index := range fields {
fields[index] = unknown
}
return sglClickHouseShape{names: nil, elements: fields, digits: -1, kind: '(', checked: false, driverMap: false, prepared: false}
}
return sglClickHouseShape{names: nil, elements: nil, digits: -1, kind: 'q', checked: false, driverMap: false, prepared: false}
}
func sglClickHouseComposite(value reflect.Value, shape sglClickHouseShape, depth int) (string, error) {
const initialCapacity = 64
text, err := sglClickHouseAppendComposite(make([]byte, 0, initialCapacity), value, shape, depth)
return string(text), err
}
func sglClickHouseAppendComposite(text []byte, value reflect.Value, shape sglClickHouseShape, depth int) ([]byte, error) {
if depth >= sglClickHouseMaxDepth {
return nil, fmt.Errorf("%w: it nests more than %d levels deep or refers to itself", errSglClickHouseValue, sglClickHouseMaxDepth)
}
depth++
switch shape.kind {
case '[':
return sglClickHouseAppendArray(text, value, shape, depth)
case '{':
return sglClickHouseAppendMap(text, value, shape, depth)
default:
return sglClickHouseAppendTuple(text, value, shape, depth)
}
}
func sglClickHouseAppendArray(text []byte, value reflect.Value, shape sglClickHouseShape, depth int) ([]byte, error) {
if value.Kind() != reflect.Slice && value.Kind() != reflect.Array {
return nil, fmt.Errorf("%w: an array needs a slice or an array, not %s", errSglClickHouseValue, value.Type())
}
text = append(text, '[')
for index := 0; index < value.Len(); index++ {
if index > 0 {
text = append(text, ',')
}
var err error
text, err = sglClickHouseAppendElement(text, value.Index(index), shape.elements[0], depth)
if err != nil {
return nil, err
}
}
return append(text, ']'), nil
}
func sglClickHouseAppendMap(text []byte, value reflect.Value, shape sglClickHouseShape, depth int) ([]byte, error) {
if value.Kind() != reflect.Map {
return nil, fmt.Errorf("%w: a map needs a Go map, not %s", errSglClickHouseValue, value.Type())
}
parts := make([]string, 0, value.Len())
entries := value.MapRange()
for entries.Next() {
part, err := sglClickHouseAppendElement(nil, entries.Key(), shape.elements[0], depth)
if err != nil {
return nil, err
}
part = append(part, ':')
part, err = sglClickHouseAppendElement(part, entries.Value(), shape.elements[1], depth)
if err != nil {
return nil, err
}
parts = append(parts, string(part))
}
slices.Sort(parts)
text = append(text, '{')
for index, part := range parts {
if index > 0 {
text = append(text, ',')
}
text = append(text, part...)
}
return append(text, '}'), nil
}
func sglClickHouseAppendTuple(text []byte, value reflect.Value, shape sglClickHouseShape, depth int) ([]byte, error) {
elements, err := sglClickHouseTupleElements(value, shape)
if err != nil {
return nil, err
}
text = append(text, '(')
for index, element := range elements {
if index > 0 {
text = append(text, ", "...)
}
text, err = sglClickHouseAppendElement(text, element, shape.elements[index], depth)
if err != nil {
return nil, err
}
}
return append(text, ')'), nil
}
func sglClickHouseTupleElements(value reflect.Value, shape sglClickHouseShape) ([]reflect.Value, error) {
var elements []reflect.Value
switch value.Kind() {
case reflect.Struct:
for index := range value.NumField() {
if !value.Type().Field(index).IsExported() {
return nil, fmt.Errorf("%w: a tuple cannot be read from %s, which has unexported fields", errSglClickHouseValue, value.Type())
}
elements = append(elements, value.Field(index))
}
case reflect.Map:
if value.Type().Key().Kind() != reflect.String || slices.Contains(shape.names, "") {
return nil, fmt.Errorf("%w: a tuple is read from a map only by field names", errSglClickHouseValue)
}
for _, name := range shape.names {
elements = append(elements, value.MapIndex(reflect.ValueOf(name).Convert(value.Type().Key())))
}
case reflect.Slice, reflect.Array:
for index := range value.Len() {
elements = append(elements, value.Index(index))
}
default:
return nil, fmt.Errorf("%w: a tuple needs a struct, a map or a slice, not %s", errSglClickHouseValue, value.Type())
}
if len(elements) != len(shape.elements) {
return nil, fmt.Errorf("%w: the tuple has %d fields but the value holds %d", errSglClickHouseValue, len(shape.elements), len(elements))
}
return elements, nil
}
func sglClickHouseScalar(value reflect.Value, shape sglClickHouseShape) (string, error) {
if shape.kind == '4' || shape.kind == '6' {
address, isAddress, failure := sglClickHouseAddress(value, shape.kind)
if failure != "" {
return "", fmt.Errorf("%w: %s", errSglClickHouseValue, failure)
}
if isAddress {
return address.String(), nil
}
}
if moment, ok := sglClickHouseMoment(value); ok {
return sglClickHouseTime(moment, shape)
}
if shape.kind == 'n' && sglClickHouseIsNumber(value) {
return sglClickHouseNumber(value), nil
}
text, err := sglClickHouseText(value)
if err == nil && shape.kind == 'n' && !sglClickHouseIsNumberText(text) {
err = fmt.Errorf("%w: %q is not a number", errSglClickHouseValue, text)
}
return text, err
}
func sglClickHouseAddress(value reflect.Value, kind byte) (netip.Addr, bool, string) {
var address netip.Addr
switch value.Type() {
case reflect.TypeFor[netip.Addr]():
reflect.ValueOf(&address).Elem().Set(value)
if !address.IsValid() {
return address, true, "the zero netip.Addr holds no address"
}
case reflect.TypeFor[net.IP]():
bytes := value.Bytes()
if len(bytes) == 0 {
return address, true, "an empty net.IP holds no address"
}
parsed, valid := netip.AddrFromSlice(bytes)
if !valid {
return address, true, fmt.Sprintf("a net.IP of %d bytes is not an IP address", len(bytes))
}
address = parsed
default:
return address, false, ""
}
address = address.WithZone("")
if kind != '4' {
return address, true, ""
}
if address.Is6() && !address.Is4In6() {
return address, true, address.String() + " is an IPv6 address, which an IPv4 value cannot hold"
}
return address.Unmap(), true, ""
}
func sglClickHouseMoment(value reflect.Value) (time.Time, bool) {
return reflect.TypeAssert[time.Time](value)
}
func sglClickHouseTime(moment time.Time, shape sglClickHouseShape) (string, error) {
switch shape.kind {
case 'd':
return moment.Format("2006-01-02"), nil
case 't':
return sglClickHouseEpoch(moment, shape.digits), nil
case 'q':
return moment.Format(time.RFC3339Nano), nil
default:
return "", fmt.Errorf("%w: a time.Time cannot be written as a number", errSglClickHouseValue)
}
}
func sglClickHouseEpoch(moment time.Time, digits int) string {
const decimalBase = 10
nanoseconds := int64(moment.Nanosecond())
if digits < 0 {
digits = sglClickHouseNanosecondDigits
for digits > 0 && nanoseconds%int64(math.Pow10(sglClickHouseNanosecondDigits-digits+sglClickHouseDigitGroup)) == 0 {
digits -= sglClickHouseDigitGroup
}
}
seconds := moment.Unix()
if digits == 0 {
return strconv.FormatInt(seconds, decimalBase)
}
scale := int64(math.Pow10(digits))
fraction := nanoseconds / int64(math.Pow10(sglClickHouseNanosecondDigits-digits))
sign := ""
if seconds < 0 {
sign = "-"
seconds = -seconds
if fraction > 0 {
seconds--
fraction = scale - fraction
}
}
return fmt.Sprintf("%s%d.%0*d", sign, seconds, digits, fraction)
}
func sglClickHouseIsNumber(value reflect.Value) bool {
switch value.Kind() {
case reflect.Bool, reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr,
reflect.Float32, reflect.Float64:
return true
}
return false
}
func sglClickHouseNumber(value reflect.Value) string {
const decimalBase = 10
switch value.Kind() {
case reflect.Bool:
return strconv.FormatBool(value.Bool())
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return strconv.FormatInt(value.Int(), decimalBase)
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
return strconv.FormatUint(value.Uint(), decimalBase)
}
number := value.Float()
switch {
case math.IsNaN(number):
return "nan"
case math.IsInf(number, 1):
return "inf"
case math.IsInf(number, -1):
return "-inf"
}
return strconv.FormatFloat(number, 'g', -1, value.Type().Bits())
}
func sglClickHouseMethods(value reflect.Value) []any {
if value.CanAddr() {
return []any{value.Interface(), value.Addr().Interface()}
}
return []any{value.Interface()}
}
func sglClickHouseHasText(value reflect.Value) bool {
for _, candidate := range sglClickHouseMethods(value) {
switch candidate.(type) {
case fmt.Stringer, interface{ String() (string, error) }, error:
return true
}
}
return false
}
func sglClickHouseText(value reflect.Value) (string, error) {
for _, candidate := range sglClickHouseMethods(value) {
switch typed := candidate.(type) {
case fmt.Stringer:
return typed.String(), nil
case interface{ String() (string, error) }:
text, err := typed.String()
if err != nil {
return "", fmt.Errorf("%w: its String method failed: %w", errSglClickHouseValue, err)
}
return text, nil
case error:
return "", fmt.Errorf("%w: an error is not a value", errSglClickHouseValue)
}
}
switch value.Kind() {
case reflect.String:
return value.String(), nil
case reflect.Slice, reflect.Array:
if value.Type().Elem().Kind() == reflect.Uint8 {
bytes := make([]byte, value.Len())
reflect.Copy(reflect.ValueOf(bytes), value)
return string(bytes), nil
}
}
if sglClickHouseIsNumber(value) {
return sglClickHouseNumber(value), nil
}
return "", fmt.Errorf("%w: a %s cannot be written as text", errSglClickHouseValue, value.Type())
}
func sglClickHouseIsNumberText(text string) bool {
if text == "true" || text == "false" {
return true
}
lower := strings.ToLower(sglClickHouseUnsigned(text))
if lower == "nan" || lower == "inf" {
return true
}
mantissa, exponent, scientific := strings.Cut(lower, "e")
if scientific {
exponent = sglClickHouseUnsigned(exponent)
if exponent == "" || strings.Trim(exponent, "0123456789") != "" {
return false
}
}
whole, fraction, _ := strings.Cut(mantissa, ".")
return whole+fraction != "" && strings.Trim(whole+fraction, "0123456789") == ""
}
func sglClickHouseUnsigned(text string) string {
if strings.HasPrefix(text, "+") || strings.HasPrefix(text, "-") {
return text[1:]
}
return text
}
func sglClickHouseParseShape(text string) (sglClickHouseShape, error) {
shape, rest, err := sglClickHouseReadShape(text, 0)
if err == nil && rest != "" {
err = fmt.Errorf("%w: %q has %q left over", errSglClickHouseShape, text, rest)
}
return shape, err
}
func sglClickHouseReadShape(text string, depth int) (sglClickHouseShape, string, error) {
shape := sglClickHouseShape{names: nil, elements: nil, digits: -1, kind: 0, checked: false, driverMap: false, prepared: false}
if text == "" || depth > sglClickHouseMaxDepth {
return shape, "", fmt.Errorf("%w: a shape is missing or nests too deeply", errSglClickHouseShape)
}
shape.kind, text = text[0], text[1:]
switch shape.kind {
case 'q', 'n', 'd', 'a', '4', '6':
return shape, text, nil
case 't':
shape.digits = 0
if text != "" && text[0] >= '0' && text[0] <= '9' {
shape.digits, text = int(text[0]-'0'), text[1:]
}
return shape, text, nil
case '[':
return sglClickHouseReadElements(shape, text, depth, ']')
case '{':
return sglClickHouseReadElements(shape, text, depth, '}')
case '(':
return sglClickHouseReadElements(shape, text, depth, ')')
}
return shape, "", fmt.Errorf("%w: %q is not a shape", errSglClickHouseShape, shape.kind)
}
func sglClickHouseReadElements(shape sglClickHouseShape, text string, depth int, closing byte) (sglClickHouseShape, string, error) {
for {
var err error
if shape, text, err = sglClickHouseReadElement(shape, text, depth); err != nil {
return shape, "", err
}
if text == "" {
return shape, "", fmt.Errorf("%w: %q is not closed", errSglClickHouseShape, string(shape.kind))
}
switch {
case text[0] == closing && (shape.kind != '{' || len(shape.elements) == 2):
return shape, text[1:], nil
case shape.kind == '(' && text[0] == ',', shape.kind == '{' && text[0] == ':' && len(shape.elements) == 1:
text = text[1:]
default:
return shape, "", fmt.Errorf("%w: %q is out of place", errSglClickHouseShape, text[:1])
}
}
}
func sglClickHouseReadElement(shape sglClickHouseShape, text string, depth int) (sglClickHouseShape, string, error) {
name := ""
if shape.kind == '(' && strings.HasPrefix(text, "'") {
var err error
if name, text, err = sglClickHouseReadName(text); err != nil {
return shape, "", err
}
}
element, rest, err := sglClickHouseReadShape(text, depth+1)
if err != nil {
return shape, "", err
}
shape.elements = append(shape.elements, element)
shape.names = append(shape.names, name)
return shape, rest, nil
}
func sglClickHouseReadName(text string) (name string, rest string, err error) {
var written strings.Builder
index := 1
for index < len(text) {
if text[index] != '\'' {
written.WriteByte(text[index])
index++
continue
}
if index+1 < len(text) && text[index+1] == '\'' {
written.WriteByte('\'')
index += 2
continue
}
if index+1 < len(text) && text[index+1] == '=' {
return written.String(), text[index+2:], nil
}
break
}
return "", "", fmt.Errorf("%w: a tuple field's name is not closed by '=", errSglClickHouseShape)
}
internal/store/landings.sql.go
// Code generated by sgl. DO NOT EDIT.
package store
import (
"context"
"time"
"github.com/ClickHouse/clickhouse-go/v2"
"github.com/google/uuid"
"github.com/shopspring/decimal"
)
const getLandingSQL = `SELECT id, catch_id, species, boxes, fish, weight_g, length_cm, price, on_ice, landed_at, tag, notes
FROM landings
WHERE id = {p_id:UInt64};`
type GetLandingRow struct {
ID uint64 `json:"id"`
CatchID uuid.UUID `json:"catch_id"`
Species string `json:"species"`
Boxes uint16 `json:"boxes"`
Fish uint32 `json:"fish"`
WeightG int64 `json:"weight_g"`
LengthCm float32 `json:"length_cm"`
Price decimal.Decimal `json:"price"`
OnIce bool `json:"on_ice"`
LandedAt time.Time `json:"landed_at"`
Tag string `json:"tag"`
Notes *string `json:"notes"`
}
func (queries *Queries) GetLanding(ctx context.Context, id uint64) (GetLandingRow, error) {
var row GetLandingRow
err := queries.reader.QueryRowContext(ctx, getLandingSQL, clickhouse.Named("p_id", sglClickHouseShapedValue(id, sglClickHouseShapes[0]))).Scan(&row.ID, &row.CatchID, &row.Species, &row.Boxes, &row.Fish, &row.WeightG, &row.LengthCm, &row.Price, &row.OnIce, &row.LandedAt, &row.Tag, &row.Notes)
if err != nil {
return GetLandingRow{}, err
}
return row, nil
}
const recordLandingSQL = `INSERT INTO landings (id, catch_id, species, boxes, fish, weight_g, length_cm, price, on_ice, landed_at, tag, notes)
VALUES (
{p_id:UInt64},
toUUID({p_catch_id:String}),
{p_species:String},
{p_boxes:UInt16},
{p_fish:UInt32},
{p_weight_g:Int64},
{p_length_cm:Float32},
toDecimal32({p_price:String}, 2),
{p_on_ice:Bool},
{p_landed_at:DateTime('UTC')},
{p_tag:FixedString(4)},
{p_notes:Nullable(String)}
);`
type RecordLandingParams struct {
ID uint64
CatchID string
Species string
Boxes uint16
Fish uint32
WeightG int64
LengthCm float32
Price string
OnIce bool
LandedAt time.Time
Tag string
Notes *string
}
func (queries *Queries) RecordLanding(ctx context.Context, params RecordLandingParams) error {
_, err := queries.writer.ExecContext(ctx, recordLandingSQL, clickhouse.Named("p_id", sglClickHouseShapedValue(params.ID, sglClickHouseShapes[0])), clickhouse.Named("p_catch_id", sglClickHouseShapedValue(params.CatchID, sglClickHouseShapes[1])), clickhouse.Named("p_species", sglClickHouseShapedValue(params.Species, sglClickHouseShapes[1])), clickhouse.Named("p_boxes", sglClickHouseShapedValue(params.Boxes, sglClickHouseShapes[0])), clickhouse.Named("p_fish", sglClickHouseShapedValue(params.Fish, sglClickHouseShapes[0])), clickhouse.Named("p_weight_g", sglClickHouseShapedValue(params.WeightG, sglClickHouseShapes[0])), clickhouse.Named("p_length_cm", sglClickHouseShapedValue(params.LengthCm, sglClickHouseShapes[0])), clickhouse.Named("p_price", sglClickHouseShapedValue(params.Price, sglClickHouseShapes[1])), clickhouse.Named("p_on_ice", sglClickHouseShapedValue(params.OnIce, sglClickHouseShapes[0])), clickhouse.Named("p_landed_at", sglClickHouseShapedValue(params.LandedAt, sglClickHouseShapes[2])), clickhouse.Named("p_tag", sglClickHouseShapedValue(params.Tag, sglClickHouseShapes[1])), clickhouse.Named("p_notes", sglClickHouseShapedValue(params.Notes, sglClickHouseShapes[1])))
return err
}
internal/store/models.go
// Code generated by sgl. DO NOT EDIT.
package store
import (
"time"
"github.com/google/uuid"
"github.com/shopspring/decimal"
)
type Landings struct {
ID uint64 `json:"id"`
CatchID uuid.UUID `json:"catch_id"`
Species string `json:"species"`
Boxes uint16 `json:"boxes"`
Fish uint32 `json:"fish"`
WeightG int64 `json:"weight_g"`
LengthCm float32 `json:"length_cm"`
Price decimal.Decimal `json:"price"`
OnIce bool `json:"on_ice"`
LandedAt time.Time `json:"landed_at"`
Tag string `json:"tag"`
Notes *string `json:"notes"`
}
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()
}
output.txt
id uint64 1
catch_id uuid.UUID 5f0c6c7e-8d0e-4b77-9a51-2c4a6f9e3b10
species string mackerel
boxes uint16 12
fish uint32 480
weight_g int64 163200
length_cm float32 31.5
price decimal.Decimal 412.50
on_ice bool true
landed_at time.Time 2026-05-01T05:30:00Z
tag string 04 a2 19 f3
notes *string landed at Newlyn