0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

GoでPostgreSQLのtsrange型を利用する

Last updated at Posted at 2025-05-19

はじめに

今回は表題通り、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, &currentRange)
		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)

→確かに更新されていそうですね!!

最後に

実装前まではちょっとイメージつかなかったんですが、実装してみるとそこまで大したことなさそうですね。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?