package dbtimesprovider import ( "bufio" "context" "database/sql" _ "embed" "encoding/json" "fmt" "strings" "time" "github.com/doug-martin/goqu/v9" "github.com/samber/lo" "prayertimes/pkg/prayer" ) //go:embed schema.sql var schema string //go:embed locations.jsonl var locationsJSON string type Provider struct { db *goqu.Database provider prayer.TimesProvider clockFunc func() time.Time } func New(db *goqu.Database, provider prayer.TimesProvider) Provider { return Provider{ provider: provider, clockFunc: time.Now, db: db, } } func (p Provider) Name() string { return "db:" + p.provider.Name() } func (p Provider) Get(ctx context.Context, location string) ([]prayer.Times, error) { times, err := p.loadTimes(ctx, location) if err == nil && len(times) != 0 { return times, nil } else if err != nil { return nil, fmt.Errorf("failed to load prayer times from db: %w", err) } times, err = p.provider.Get(ctx, location) if err != nil { return nil, fmt.Errorf("failed to get prayer times: %w", err) } if len(times) > 1 { if err := p.saveTimes(ctx, location, times); err != nil { return nil, fmt.Errorf("failed to save times to db: %w", err) } } return times, nil } func Migrate(con *sql.DB) error { db := goqu.New("sqlite3", con) if _, err := db.Exec(schema); err != nil { return fmt.Errorf("failed to migrate: %w", err) } count, _ := db.From("locations").Count() if count > 0 { return nil } type entry struct { ID int `json:"id" db:"id"` Country string `json:"country" db:"country"` Region string `json:"region" db:"region"` City string `json:"city" db:"city"` } s := bufio.NewScanner(strings.NewReader(locationsJSON)) tx, err := db.Begin() if err != nil { return fmt.Errorf("failed to begin tx: %w", err) } if err := tx.Wrap(func() error { for s.Scan() { var e entry if err := json.Unmarshal(s.Bytes(), &e); err != nil { return fmt.Errorf("failed to parse as json: %w", err) } q := tx.Insert("locations"). OnConflict(goqu.DoNothing()). Rows(e) if _, err := q.Executor().Exec(); err != nil { return fmt.Errorf("failed to insert location: %w", err) } } return nil }); err != nil { return err } return nil } type prayerTimesRow struct { ProviderID int64 `db:"provider_id"` LocationID string `db:"location_id"` Date time.Time `db:"date"` Fajr string `db:"fajr"` Sunrise string `db:"sunrise"` Dhuhr string `db:"dhuhr"` Asr string `db:"asr"` Maghrib string `db:"maghrib"` Isha string `db:"isha"` } func (r prayerTimesRow) toDomain() prayer.Times { return prayer.Times{ Date: r.Date, Fajr: r.Fajr, Sunrise: r.Sunrise, Dhuhr: r.Dhuhr, Asr: r.Asr, Maghrib: r.Maghrib, Isha: r.Isha, } } func (p Provider) saveTimes(ctx context.Context, locationID string, times []prayer.Times) error { providerID, err := p.saveProvider(ctx, p.provider.Name()) if err != nil { return err } rows := lo.Map(times, func(item prayer.Times, _ int) prayerTimesRow { return prayerTimesRow{ ProviderID: providerID, LocationID: locationID, Date: item.Date, Fajr: item.Fajr, Sunrise: item.Sunrise, Dhuhr: item.Dhuhr, Asr: item.Asr, Maghrib: item.Maghrib, Isha: item.Isha, } }) q := p.db. Insert("prayer_times"). OnConflict(goqu.DoNothing()). Rows(rows) if _, err := q.Executor().ExecContext(ctx); err != nil { return fmt.Errorf("failed to save times: %w", err) } return nil } func (p Provider) loadTimes(ctx context.Context, locationID string) ([]prayer.Times, error) { q := p.db. From(goqu.T("prayer_times").As("pt")). Join(goqu.T("providers").As("p"), goqu.On(goqu.I("p.id").Eq(goqu.I("pt.provider_id")))). Where( goqu.I("p.name").Eq(p.provider.Name()), goqu.I("pt.location_id").Eq(locationID), goqu.I("pt.date").Gte(p.clockFunc()), ). Limit(100) var rows []prayerTimesRow if err := q.ScanStructsContext(ctx, &rows); err != nil { return nil, fmt.Errorf("failed to scan times: %w", err) } return lo.Map(rows, func(row prayerTimesRow, _ int) prayer.Times { return row.toDomain() }), nil } func (p Provider) saveProvider(ctx context.Context, name string) (int64, error) { q := p.db.Insert("providers"). OnConflict(goqu.DoUpdate("name", goqu.Record{"name": name})). Rows(goqu.Record{"name": name}). Returning("id") var id int64 _, err := q.Executor().ScanValContext(ctx, &id) if err != nil { return 0, fmt.Errorf("failed to insert provider: %w", err) } return id, nil }