Batch and copyfrom: DuckDB and PostgreSQL
Every file of the DuckDB and PostgreSQL versions side by side, with the lines that differ marked. 2 parameter or column types differ in Go.
Configuration
sgl.yamldiffers
DuckDB
PostgreSQL
1
version: 11
version: 12
2
3
databases:3
databases:4
- name: tides4
- name: tides5
dialect: duckdb5
dialect: postgres6
migrations: db/migrations6
migrations: db/migrations7
queries: db/queries7
queries: db/queries8
go:8
go:9
package: store9
package: store10
out: internal/store10
out: internal/store11
driver: database/sql12
migrate:13
dsn: ${env:DATABASE_URL}Migrations
db/migrations/0001_readings.up.sqldiffers
DuckDB
PostgreSQL
1
CREATE SEQUENCE reading_ids;2
3
CREATE TABLE readings (1
CREATE TABLE readings (4
id BIGINT PRIMARY KEY DEFAULT nextval('reading_ids'),2
station TEXT NOT NULL,5
station VARCHAR NOT NULL,3
minute BIGINT NOT NULL,6
minute INTEGER NOT NULL,4
height_cm BIGINT NOT NULL,7
height_cm INTEGER NOT NULL,5
note TEXT,8
note VARCHAR6
PRIMARY KEY (station, minute)9
);7
);db/migrations/0001_readings.down.sqldiffers
DuckDB
PostgreSQL
1
DROP TABLE readings;1
DROP TABLE readings;2
DROP SEQUENCE reading_ids;Queries
RecordReadingsidentical
DuckDB · db/queries/readings.sql
PostgreSQL · db/queries/readings.sql
1
-- sgl.query(RecordReadings, batch)1
-- sgl.query(RecordReadings, batch)2
INSERT INTO readings (station, minute, height_cm, note) VALUES ($1, $2, $3, $4);2
INSERT INTO readings (station, minute, height_cm, note) VALUES ($1, $2, $3, $4);SummariseReadingsidentical
DuckDB · db/queries/readings.sql
PostgreSQL · db/queries/readings.sql
1
-- sgl.query(SummariseReadings, many)1
-- sgl.query(SummariseReadings, many)2
SELECT station, COUNT(*) AS readings, COUNT(note) AS noted2
SELECT station, COUNT(*) AS readings, COUNT(note) AS noted3
FROM readings3
FROM readings4
GROUP BY station4
GROUP BY station5
ORDER BY station;5
ORDER BY station;What sgl generates
RecordReadingsdiffers
DuckDB
PostgreSQL
1
func (queries *Queries) RecordReadings(ctx context.Context, params []RecordReadingsParams) error1
func (queries *Queries) RecordReadings(ctx context.Context, params []RecordReadingsParams) error2
2
3
type RecordReadingsParams struct {3
type RecordReadingsParams struct {4
Station string4
Station string5
Minute int325
Minute int646
HeightCm int326
HeightCm int647
Note *string7
Note *string8
}8
}SummariseReadingsidentical
DuckDB
PostgreSQL
1
func (queries *Queries) SummariseReadings(ctx context.Context) ([]SummariseReadingsRow, error)1
func (queries *Queries) SummariseReadings(ctx context.Context) ([]SummariseReadingsRow, error)2
2
3
type SummariseReadingsRow struct {3
type SummariseReadingsRow struct {4
Station string `json:"station"`4
Station string `json:"station"`5
Readings int64 `json:"readings"`5
Readings int64 `json:"readings"`6
Noted int64 `json:"noted"`6
Noted int64 `json:"noted"`7
}7
}The program
example.godiffers
DuckDB
PostgreSQL
1
package main1
package main2
2
3
import (3
import (4
"context"4
"context"5
"database/sql"5
"database/sql"6
"fmt"6
"fmt"7
"io"7
"io"8
8
9
"seagull.sh/seagull/examples/duckdb/022_bulk_writes/internal/store"9
"seagull.sh/seagull/examples/postgres/022_bulk_writes/internal/store"10
)10
)11
11
12
const (12
const (13
// minutesPerWeek is how many readings each tide gauge takes in a week, one a minute.13
// minutesPerWeek is how many readings each tide gauge takes in a week.14
minutesPerWeek = 7 * 24 * 6014
minutesPerWeek = 7 * 24 * 6015
)15
)16
16
17
// statementCounter is a store.DBTX that counts the statements sent through ExecContext.17
// statementCounter is a store.DBTX that counts the statements sent through ExecContext.18
// QueryContext and QueryRowContext come from the embedded *sql.DB.18
// QueryContext and QueryRowContext come from the embedded *sql.DB.19
type statementCounter struct {19
type statementCounter struct {20
*sql.DB20
*sql.DB21
21
22
// statements is how many times ExecContext has been called.22
// statements is how many times ExecContext has been called.23
statements int23
statements int24
}24
}25
25
26
// ExecContext counts the statement, then runs it on the database.26
// ExecContext counts the statement, then runs it on the database.27
//27
//28
// Takes query (string) which is the SQL to run.28
// Takes query (string) which is the SQL to run.29
// Takes arguments (...any) which are its bind values.29
// Takes arguments (...any) which are its bind values.30
//30
//31
// Returns sql.Result which is the database's result.31
// Returns sql.Result which is the database's result.32
// Returns error when the statement fails.32
// Returns error when the statement fails.33
func (counter *statementCounter) ExecContext(ctx context.Context, query string, arguments ...any) (sql.Result, error) {33
func (counter *statementCounter) ExecContext(ctx context.Context, query string, arguments ...any) (sql.Result, error) {34
counter.statements++34
counter.statements++35
return counter.DB.ExecContext(ctx, query, arguments...)35
return counter.DB.ExecContext(ctx, query, arguments...)36
}36
}37
37
38
// run records a week of readings from two tide gauges in one batch call, shows how many38
// run records a week of readings from two tide gauges in one batch call, shows how many39
// INSERT statements that took, and summarises what landed in the table.39
// INSERT statements that took, and summarises what landed in the table.40
//40
//41
// Takes database (*sql.DB) which is a migrated database.41
// Takes database (*sql.DB) which is a migrated database.42
// Takes output (io.Writer) which receives the statement counts and the summary.42
// Takes output (io.Writer) which receives the statement counts and the summary.43
//43
//44
// Returns error when a query fails.44
// Returns error when a query fails.45
func run(ctx context.Context, database *sql.DB, output io.Writer) error {45
func run(ctx context.Context, database *sql.DB, output io.Writer) error {46
counter := &statementCounter{DB: database}46
counter := &statementCounter{DB: database}47
queries := store.New(counter)47
queries := store.New(counter)48
48
49
// An empty batch is a no-op: nothing is sent to the database.49
// An empty batch is a no-op: nothing is sent to the database.50
if err := queries.RecordReadings(ctx, nil); err != nil {50
if err := queries.RecordReadings(ctx, nil); err != nil {51
return fmt.Errorf("recording no readings: %w", err)51
return fmt.Errorf("recording no readings: %w", err)52
}52
}53
fmt.Fprintf(output, "0 readings sent in %d statements\n", counter.statements)53
fmt.Fprintf(output, "0 readings sent in %d statements\n", counter.statements)54
54
55
readings := tideReadings("harbour", "pier")55
readings := tideReadings("harbour", "pier")56
56
57
// A batch query takes a slice of Params, one per row. On database/sql the generated57
// A batch query takes a slice of Params, one per row. On database/sql the generated58
// method writes them as multi-row INSERTs, chunked so that no statement binds more than58
// method writes them as multi-row INSERTs, renumbering the placeholders $1, $2 and on,59
// 65535 variables, the limit declared by Seagull's DuckDB dialect: four columns a row59
// chunked so that no statement binds more than 65535 variables, the most the PostgreSQL60
// allows 16383 rows a statement, so a week's 20160 rows take two. copyfrom generates the60
// protocol can carry: four columns a row allows 16383 rows a statement, so a week from61
// same code on database/sql.61
// two gauges takes two. copyfrom generates the same code on database/sql; only the pgx62
// driver uses COPY.62
//63
//63
// Each chunk is its own statement, so run the call inside RunInTx (concept 23) when64
// Each chunk is its own statement, so run the call inside RunInTx (concept 23) when64
// every row must land or none.65
// every row must land or none.65
if err := queries.RecordReadings(ctx, readings); err != nil {66
if err := queries.RecordReadings(ctx, readings); err != nil {66
return fmt.Errorf("recording readings: %w", err)67
return fmt.Errorf("recording readings: %w", err)67
}68
}68
fmt.Fprintf(output, "%d readings sent in %d statements\n", len(readings), counter.statements)69
fmt.Fprintf(output, "%d readings sent in %d statements\n", len(readings), counter.statements)69
70
70
summary, err := queries.SummariseReadings(ctx)71
summary, err := queries.SummariseReadings(ctx)71
if err != nil {72
if err != nil {72
return fmt.Errorf("summarising readings: %w", err)73
return fmt.Errorf("summarising readings: %w", err)73
}74
}74
for _, row := range summary {75
for _, row := range summary {75
fmt.Fprintf(output, "%-8s %d readings, %d with a note\n", row.Station, row.Readings, row.Noted)76
fmt.Fprintf(output, "%-8s %d readings, %d with a note\n", row.Station, row.Readings, row.Noted)76
}77
}77
return nil78
return nil78
}79
}79
80
80
// tideReadings makes one reading a minute for a week at each station. The height rises81
// tideReadings makes one reading a minute for a week at each station. The height rises81
// and falls in a simple saw-tooth, and the reading on the hour carries a note.82
// and falls in a simple saw-tooth, and the reading on the hour carries a note.82
//83
//83
// Takes stations (...string) which name the gauges.84
// Takes stations (...string) which name the gauges.84
//85
//85
// Returns []store.RecordReadingsParams which holds every reading, ready for the batch.86
// Returns []store.RecordReadingsParams which holds every reading, ready for the batch.86
func tideReadings(stations ...string) []store.RecordReadingsParams {87
func tideReadings(stations ...string) []store.RecordReadingsParams {87
readings := make([]store.RecordReadingsParams, 0, len(stations)*minutesPerWeek)88
readings := make([]store.RecordReadingsParams, 0, len(stations)*minutesPerWeek)88
for _, station := range stations {89
for _, station := range stations {89
for minute := range int32(minutesPerWeek) {90
for minute := range int64(minutesPerWeek) {90
var note *string91
var note *string91
if minute%60 == 0 {92
if minute%60 == 0 {92
note = new(fmt.Sprintf("hour %d check", minute/60))93
note = new(fmt.Sprintf("hour %d check", minute/60))93
}94
}94
readings = append(readings, store.RecordReadingsParams{95
readings = append(readings, store.RecordReadingsParams{95
Station: station,96
Station: station,96
Minute: minute,97
Minute: minute,97
HeightCm: 200 + minute%360,98
HeightCm: 200 + minute%360,98
Note: note,99
Note: note,99
})100
})100
}101
}101
}102
}102
return readings103
return readings103
}104
}Go type differences
The same SQL types map to different Go types, because each database reports them differently.
| Query | Field | DuckDB | PostgreSQL |
|---|---|---|---|
| RecordReadings | parameter minute | int32 | int64 |
| RecordReadings | parameter height_cm | int32 | int64 |