GoでPostgreSQLのtsrange型を利用する

## はじめに
今回は表題通り、GoでPostgreSQLのtsrange型を利用していきます！
そもそもtsrange型って何って人は、[記事](https://qiita.com/koji0705/items/2e7af1b289ee7b7508dc)を読んでください！

## 今回のおはなし
`tsrange`型はGoの標準の型として提供されていません。あれ？そうなの？って思う人もいるかと思う方もいるかもしれないんですが、PostgreSQLでしか利用しない型なので、納得と言えば納得ですね。そのため、今回は標準ライブラリで実装する方法、独自で型を定義して実装する方法の2パターンを記載したいと思います！

## 事前準備
実行できる環境を用意します。ここら辺は適当に読み飛ばしてもらって大丈夫です。実際に動かしてみたい人は使ってみてください。

<details><summary>事前準備</summary>

```shell:ディレクトリ構成
~/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
```

```yml: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: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"] 
```

```sql: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)'); 
```

</details>

## 取得の実装

```go: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)
}
```

```go: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
}
```


```go: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
}
```

```go: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できるので便利でいいですね！

```shell:実行結果
~/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文
```go: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)
}
```

```go: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
}
```

```shell:
~/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を流してデータが入っているか確認。
```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処理
```go: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)
}
```

```go: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
}
```

```shell:実行結果
~/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
```

```sql:
~/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)
```
→確かに更新されていそうですね！！

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