はじめに
今回は表題通り、GoでPostgreSQLのtsrange型を利用していきます!
そもそもtsrange型って何って人は、記事を読んでください!
今回のおはなし
tsrange
型はGoの標準の型として提供されていません。あれ?そうなの?って思う人もいるかと思う方もいるかもしれないんですが、PostgreSQLでしか利用しない型なので、納得と言えば納得ですね。そのため、今回は標準ライブラリで実装する方法、独自で型を定義して実装する方法の2パターンを記載したいと思います!
事前準備
実行できる環境を用意します。ここら辺は適当に読み飛ばしてもらって大丈夫です。実際に動かしてみたい人は使ってみてください。
事前準備
ディレクトリ構成
~/develop/go_tsrange (main)$ tree -I pg_data/
.
├── db
│ ├── queries.go
│ └── setup.go
├── docker-compose.yml
├── Dockerfile
├── go.mod
├── go.sum
├── init.sql
├── main
├── main.go
├── Makefile
├── models
│ └── time_schedule.go
└── tmp
4 directories, 11 files
docker-compose.yml
version: '3.8'
services:
postgres:
image: postgres:16
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: go_tsrange
ports:
- "5444:5432"
volumes:
- ./pg_data:/var/lib/postgresql/data
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
app:
build:
context: .
dockerfile: Dockerfile
volumes:
- .:/app
depends_on:
- postgres
environment:
DB_HOST: postgres
DB_USER: postgres
DB_PASSWORD: postgres
DB_NAME: go_tsrange
DB_PORT: 5444
env_file:
- .env
volumes:
postgres_data:
Dockerfile
FROM golang:1.24
WORKDIR /app
COPY go.mod ./
RUN go mod download
# Install air for hot reload
RUN go install github.com/air-verse/air@latest
COPY . .
RUN go build -o main .
CMD ["air"]
init.sql
DROP TABLE IF EXISTS "time_schedule";
CREATE TABLE "time_schedule" (
"id" SERIAL PRIMARY KEY,
"title" text NOT NULL,
"time_range" tsrange NOT NULL
);
-- Insert sample data
INSERT INTO "time_schedule" ("title", "time_range") VALUES
('Morning Meeting', '[2024-03-20 09:00:00, 2024-03-20 10:00:00)'),
('Lunch Break', '[2024-03-20 12:00:00, 2024-03-20 13:00:00)'),
('Project Review', '[2024-03-20 15:00:00, 2024-03-20 16:30:00)');
取得の実装
main.go
package main
import (
"context"
"fmt"
"log"
"go_tsrange/db"
)
func main() {
ctx := context.Background()
database, err := db.SetupDB()
if err != nil {
log.Fatalf("Failed to setup database: %v", err)
}
defer database.Close()
// 1. 文字列として取得
schedulesWithString, err := db.GetSchedulesWithString(ctx, database)
if err != nil {
log.Fatalf("Failed to fetch schedules with string: %v", err)
}
// 2. TimeRange型として取得
schedulesWithRange, err := db.GetSchedulesWithRange(ctx, database)
if err != nil {
log.Fatalf("Failed to fetch schedules with range: %v", err)
}
// 結果をJSON形式で出力
fmt.Println("=== Schedules with string ===")
schedulesWithStringData := schedulesWithString[0].TimeRange
fmt.Println("schedulesWithStringData", schedulesWithStringData)
fmt.Println("\n=== Schedules with range ===")
fmt.Println("schedulesWithRange[0].TimeRange", schedulesWithRange[0].TimeRange)
fmt.Println("schedulesWithRange[0].TimeRange.Start", schedulesWithRange[0].TimeRange.Start)
fmt.Println("schedulesWithRange[0].TimeRange.End", schedulesWithRange[0].TimeRange.End)
}
db/setup.go
package db
import (
"context"
"database/sql"
"fmt"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect/pgdialect"
"github.com/uptrace/bun/driver/pgdriver"
)
// SetupDB initializes and returns a database connection
func SetupDB() (*bun.DB, error) {
dsn := "postgres://postgres:postgres@localhost:5444/go_tsrange?sslmode=disable"
sqldb := sql.OpenDB(pgdriver.NewConnector(pgdriver.WithDSN(dsn)))
db := bun.NewDB(sqldb, pgdialect.New())
ctx := context.Background()
if err := db.PingContext(ctx); err != nil {
return nil, fmt.Errorf("failed to connect to PostgreSQL: %v", err)
}
return db, nil
}
db/queries.go
package db
import (
"context"
"fmt"
"go_tsrange/models"
"github.com/uptrace/bun"
)
// GetSchedulesWithString retrieves schedules with time_range as string
func GetSchedulesWithString(ctx context.Context, db *bun.DB) ([]models.TimeScheduleWithString, error) {
var schedules []models.TimeScheduleWithString
err := db.NewSelect().
Model(&schedules).
Scan(ctx)
if err != nil {
return nil, fmt.Errorf("failed to fetch records with string: %v", err)
}
return schedules, nil
}
// GetSchedulesWithRange retrieves schedules with time_range as TimeRange type
func GetSchedulesWithRange(ctx context.Context, db *bun.DB) ([]models.TimeScheduleWithRange, error) {
var schedules []models.TimeScheduleWithRange
err := db.NewSelect().
Model(&schedules).
Scan(ctx)
if err != nil {
return nil, fmt.Errorf("failed to fetch records with range: %v", err)
}
return schedules, nil
}
models/time_schedule.go
package models
import (
"database/sql/driver"
"fmt"
"strings"
"time"
"github.com/uptrace/bun"
)
// TimeRange は開始時刻と終了時刻を持つ時間範囲を表す構造体です。
// PostgreSQLのtsrange型を扱うためにsql.Scannerとdriver.Valuerインターフェースを実装しています。
type TimeRange struct {
Start time.Time
End time.Time
}
// Scan はsql.Scannerインターフェースを実装し、PostgreSQLのtsrange値をTimeRange構造体に変換します。
// 入力形式は ["2024-03-20 09:00:00","2024-03-20 10:00:00") のような形式である必要があります。
//
// 使用例:
//
// var tr TimeRange
// err := tr.Scan([]byte(`["2024-03-20 09:00:00","2024-03-20 10:00:00")`))
func (tr *TimeRange) Scan(src interface{}) error {
if src == nil {
return nil
}
var srcStr string
switch v := src.(type) {
case string:
srcStr = v
case []byte:
srcStr = string(v)
default:
return fmt.Errorf("cannot scan %T into TimeRange", src)
}
if len(srcStr) < 2 {
return fmt.Errorf("invalid tsrange format: %q", srcStr)
}
// Remove the leading and trailing bracket/parenthesis
// e.g., "[start,end)" → "start,end"
s := srcStr[1 : len(srcStr)-1]
parts := strings.SplitN(s, ",", 2)
if len(parts) != 2 {
return fmt.Errorf("invalid tsrange format: %q", srcStr)
}
startStr := strings.Trim(parts[0], "\" ")
endStr := strings.Trim(parts[1], "\" ")
startTime, err := time.Parse("2006-01-02 15:04:05", startStr)
if err != nil {
return fmt.Errorf("failed to parse start time %q: %w", startStr, err)
}
endTime, err := time.Parse("2006-01-02 15:04:05", endStr)
if err != nil {
return fmt.Errorf("failed to parse end time %q: %w", endStr, err)
}
tr.Start = startTime
tr.End = endTime
return nil
}
// Value はdriver.Valuerインターフェースを実装し、TimeRange構造体をPostgreSQLのtsrange値に変換します。
// 出力形式は [2024-03-20 09:00:00,2024-03-20 10:00:00) のような形式になります。
//
// 使用例:
//
// tr := TimeRange{Start: startTime, End: endTime}
// val, err := tr.Value()
func (tr TimeRange) Value() (driver.Value, error) {
return fmt.Sprintf("[%s,%s)",
tr.Start.Format("2006-01-02 15:04:05"),
tr.End.Format("2006-01-02 15:04:05")), nil
}
// TimeScheduleWithString 文字列としてtime_rangeを扱うモデル
type TimeScheduleWithString struct {
bun.BaseModel `bun:"table:time_schedule"`
ID int `json:"id" db:"id,pk"`
Title string `json:"title" db:"title"`
TimeRange string `json:"time_range" db:"time_range"`
}
// TimeScheduleWithRange カスタム型としてtime_rangeを扱うモデル
type TimeScheduleWithRange struct {
bun.BaseModel `bun:"table:time_schedule"`
ID int `json:"id" db:"id,pk"`
Title string `json:"title" db:"title"`
TimeRange TimeRange `json:"time_range" db:"time_range"`
}
→ここら辺が重要なところですね。素直にstringで定義する。これが1つの手ですね。
もう1つは、独自の型を定義します。StartとEndでParseできるので便利でいいですね!
実行結果
~/develop/go_tsrange (main)$ go run main.go
=== Schedules with string ===
schedulesWithStringData ["2024-03-20 09:00:00","2024-03-20 10:00:00")
=== Schedules with range ===
schedulesWithRange[0].TimeRange {2024-03-20 09:00:00 +0000 UTC 2024-03-20 10:00:00 +0000 UTC}
schedulesWithRange[0].TimeRange.Start 2024-03-20 09:00:00 +0000 UTC
schedulesWithRange[0].TimeRange.End 2024-03-20 10:00:00 +0000 UTC
insert文
main.go
package main
import (
"context"
"fmt"
"log"
"time"
"go_tsrange/db"
)
func main() {
ctx := context.Background()
database, err := db.SetupDB()
if err != nil {
log.Fatalf("Failed to setup database: %v", err)
}
defer database.Close()
// 新規スケジュールの作成
startTime := time.Date(2024, 3, 21, 10, 0, 0, 0, time.Local)
endTime := time.Date(2024, 3, 21, 11, 0, 0, 0, time.Local)
newSchedule, err := db.CreateTimeSchedule(ctx, database, "新規ミーティング", startTime, endTime)
if err != nil {
log.Fatalf("Failed to create schedule: %v", err)
}
fmt.Printf("Created new schedule: %+v\n", newSchedule)
// 1. 文字列として取得
schedulesWithString, err := db.GetSchedulesWithString(ctx, database)
if err != nil {
log.Fatalf("Failed to fetch schedules with string: %v", err)
}
// 2. TimeRange型として取得
schedulesWithRange, err := db.GetSchedulesWithRange(ctx, database)
if err != nil {
log.Fatalf("Failed to fetch schedules with range: %v", err)
}
// 結果をJSON形式で出力
fmt.Println("=== Schedules with string ===")
schedulesWithStringData := schedulesWithString[0].TimeRange
fmt.Println("schedulesWithStringData", schedulesWithStringData)
fmt.Println("\n=== Schedules with range ===")
fmt.Println("schedulesWithRange[0].TimeRange", schedulesWithRange[0].TimeRange)
fmt.Println("schedulesWithRange[0].TimeRange.Start", schedulesWithRange[0].TimeRange.Start)
fmt.Println("schedulesWithRange[0].TimeRange.End", schedulesWithRange[0].TimeRange.End)
}
db/queries.go
// CreateTimeSchedule は新しいスケジュールを作成します
// startTimeとendTimeは時間範囲の開始時刻と終了時刻を指定します
func CreateTimeSchedule(ctx context.Context, db *bun.DB, title string, startTime, endTime time.Time) (*models.TimeScheduleWithRange, error) {
schedule := &models.TimeScheduleWithRange{
Title: title,
TimeRange: models.TimeRange{
Start: startTime,
End: endTime,
},
}
_, err := db.NewInsert().
Model(schedule).
Returning("id").
Exec(ctx)
if err != nil {
return nil, err
}
return schedule, nil
}
~/develop/go_tsrange (main)$ go run main.go
Created new schedule: &{BaseModel:{} ID:4 Title:新規ミーティング TimeRange:{Start:2024-03-21 10:00:00 +0900 JST End:2024-03-21 11:00:00 +0900 JST}}
=== Schedules with string ===
schedulesWithStringData ["2024-03-20 09:00:00","2024-03-20 10:00:00")
=== Schedules with range ===
schedulesWithRange[0].TimeRange {2024-03-20 09:00:00 +0000 UTC 2024-03-20 10:00:00 +0000 UTC}
schedulesWithRange[0].TimeRange.Start 2024-03-20 09:00:00 +0000 UTC
schedulesWithRange[0].TimeRange.End 2024-03-20 10:00:00 +0000 UTC
→実際にSQLを流してデータが入っているか確認。
~/develop/go_tsrange (main)$ docker compose exec postgres psql -U postgres -d go_tsrange -c "SELECT * FROM time_schedule;"
id | title | time_range
----+------------------+-----------------------------------------------
1 | Morning Meeting | ["2024-03-20 09:00:00","2024-03-20 10:00:00")
2 | Lunch Break | ["2024-03-20 12:00:00","2024-03-20 13:00:00")
3 | Project Review | ["2024-03-20 15:00:00","2024-03-20 16:30:00")
4 | 新規ミーティング | ["2024-03-21 10:00:00","2024-03-21 11:00:00")
(4 rows)
→入っていそうですね!!
update処理
main.go
func main() {
ctx := context.Background()
database, err := db.SetupDB()
if err != nil {
log.Fatalf("Failed to setup database: %v", err)
}
defer database.Close()
// スケジュールの更新
updateStartTime := time.Date(2025, 3, 21, 14, 0, 0, 0, time.Local)
updateEndTime := time.Date(2025, 3, 21, 15, 0, 0, 0, time.Local)
updatedSchedule, err := db.UpdateTimeSchedule(ctx, database, 4, "更新されたミーティング", updateStartTime, updateEndTime)
if err != nil {
log.Fatalf("Failed to update schedule: %v", err)
}
fmt.Printf("Updated schedule: %+v\n", updatedSchedule)
// 1. 文字列として取得
schedulesWithString, err := db.GetSchedulesWithString(ctx, database)
if err != nil {
log.Fatalf("Failed to fetch schedules with string: %v", err)
}
// 2. TimeRange型として取得
schedulesWithRange, err := db.GetSchedulesWithRange(ctx, database)
if err != nil {
log.Fatalf("Failed to fetch schedules with range: %v", err)
}
// 結果をJSON形式で出力
fmt.Println("=== Schedules with string ===")
schedulesWithStringData := schedulesWithString[0].TimeRange
fmt.Println("schedulesWithStringData", schedulesWithStringData)
fmt.Println("\n=== Schedules with range ===")
fmt.Println("schedulesWithRange[0].TimeRange", schedulesWithRange[0].TimeRange)
fmt.Println("schedulesWithRange[0].TimeRange.Start", schedulesWithRange[0].TimeRange.Start)
fmt.Println("schedulesWithRange[0].TimeRange.End", schedulesWithRange[0].TimeRange.End)
}
db/queries.go
// UpdateTimeSchedule は指定されたIDのスケジュールを更新します
// id: 更新対象のスケジュールID
// title: 新しいタイトル(空文字列の場合は更新しない)
// startTime: 新しい開始時刻(ゼロ値の場合は更新しない)
// endTime: 新しい終了時刻(ゼロ値の場合は更新しない)
func UpdateTimeSchedule(ctx context.Context, db *bun.DB, id int, title string, startTime, endTime time.Time) (*models.TimeScheduleWithRange, error) {
// 更新対象のスケジュールを取得
schedule := &models.TimeScheduleWithRange{}
err := db.NewSelect().
Model(schedule).
Where("id = ?", id).
Scan(ctx)
if err != nil {
return nil, fmt.Errorf("failed to fetch schedule: %v", err)
}
// 更新するフィールドを設定
update := db.NewUpdate().
Model(schedule).
Where("id = ?", id)
if title != "" {
update.Set("title = ?", title)
schedule.Title = title
}
// 時間範囲の更新
if !startTime.IsZero() || !endTime.IsZero() {
// 既存の時間範囲を取得
var currentRange models.TimeRange
err := db.NewSelect().
Column("time_range").
Model((*models.TimeScheduleWithRange)(nil)).
Where("id = ?", id).
Scan(ctx, ¤tRange)
if err != nil {
return nil, fmt.Errorf("failed to fetch current time range: %v", err)
}
// 新しい時間範囲を設定
newRange := currentRange
if !startTime.IsZero() {
newRange.Start = startTime
}
if !endTime.IsZero() {
newRange.End = endTime
}
// 時間範囲を更新
update.Set("time_range = ?", newRange)
schedule.TimeRange = newRange
}
// 更新を実行
_, err = update.Exec(ctx)
if err != nil {
return nil, fmt.Errorf("failed to update schedule: %v", err)
}
return schedule, nil
}
実行結果
~/develop/go_tsrange (main)$ go run main.go
Updated schedule: &{BaseModel:{} ID:4 Title:更新されたミーティング TimeRange:{Start:2025-03-21 14:00:00 +0900 JST End:2025-03-21 15:00:00 +0900 JST}}
=== Schedules with string ===
schedulesWithStringData ["2024-03-20 09:00:00","2024-03-20 10:00:00")
=== Schedules with range ===
schedulesWithRange[0].TimeRange {2024-03-20 09:00:00 +0000 UTC 2024-03-20 10:00:00 +0000 UTC}
schedulesWithRange[0].TimeRange.Start 2024-03-20 09:00:00 +0000 UTC
schedulesWithRange[0].TimeRange.End 2024-03-20 10:00:00 +0000 UTC
~/develop/go_tsrange (main)$ docker compose exec postgres psql -U postgres -d go_tsrange -c "SELECT * FROM
time_schedule WHERE id = 4;"
id | title | time_range
----+------------------------+-----------------------------------------------
4 | 更新されたミーティング | ["2025-03-21 14:00:00","2025-03-21 15:00:00")
(1 row)
→確かに更新されていそうですね!!
最後に
実装前まではちょっとイメージつかなかったんですが、実装してみるとそこまで大したことなさそうですね。