feat: initial sqlite store

This commit is contained in:
juancwu 2026-05-06 00:16:39 +00:00
commit 9918c9918f
10 changed files with 560 additions and 6 deletions

14
go.mod
View file

@ -2,4 +2,16 @@ module git.juancwu.dev/juancwu/pase
go 1.26.2
require github.com/oklog/ulid/v2 v2.1.1 // indirect
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/oklog/ulid/v2 v2.1.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.42.0 // indirect
modernc.org/libc v1.72.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.50.0 // indirect
)

21
go.sum
View file

@ -1,3 +1,24 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
modernc.org/libc v1.72.0 h1:IEu559v9a0XWjw0DPoVKtXpO2qt5NVLAnFaBbjq+n8c=
modernc.org/libc v1.72.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.50.0 h1:eMowQSWLK0MeiQTdmz3lqoF5dqclujdlIKeJA11+7oM=
modernc.org/sqlite v1.50.0/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=

View file

@ -4,6 +4,7 @@ import (
"context"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/base64"
"errors"
"fmt"
@ -74,8 +75,8 @@ func (s *Service) RegisterUser(ctx context.Context, email string) (*store.User,
timeoutCtx, cancel := context.WithTimeout(ctx, s.operationTimeout)
defer cancel()
nullString := store.NullString{Valid: false}
nullTime := store.NullTime{Valid: false}
nullString := store.NullString{NullString: sql.NullString{Valid: false}}
nullTime := store.NullTime{NullTime: sql.NullTime{Valid: false}}
user := store.User{
ID: s.idGenerator.NewID(),
Email: email,
@ -86,7 +87,7 @@ func (s *Service) RegisterUser(ctx context.Context, email string) (*store.User,
ProfileImageURL: nullString,
Status: store.UserStatusActive,
StatusReason: "New registration",
StatusReason: nullString,
StatusChangedAt: nullTime,
StatusExpiresAt: nullTime,

View file

@ -3,5 +3,8 @@ package store
import "errors"
var (
ErrUserEmailConflict = errors.New("pase.store: user email conflict")
ErrUserEmailConflict = errors.New("pase: user email conflict")
ErrEmailAlreadyExists = errors.New("pase: email already exists")
ErrUsernameAlreadyExists = errors.New("pase: username already exists")
ErrUserNotFound = errors.New("pase: user not found")
)

View file

@ -26,7 +26,7 @@ type User struct {
ProfileImageURL NullString
Status UserStatus
StatusReason string
StatusReason NullString
StatusChangedAt NullTime
StatusExpiresAt NullTime

33
store/queries.go Normal file
View file

@ -0,0 +1,33 @@
package store
type Queries struct {
CreateUser string
GetUserByID string
}
var CanonicalQueries Queries = Queries{
CreateUser: `
INSERT INTO pase_users (
id, email, email_verified_at,
username, username_normalized, display_name, profile_image_url,
status, status_reason, status_changed_at, status_expires_at,
failed_login_count, last_failed_login_at,
created_at, updated_at
) VALUES (?,?,?, ?,?,?,?, ?,?,?,?, ?,?, ?,?);`,
GetUserByID: `
SELECT
id, email, email_verified_at,
username, username_normalized, display_name, profile_image_url,
status, status_reason, status_changed_at, status_expires_at,
failed_login_count, last_failed_login_at,
created_at, updated_at
FROM pase_users
WHERE id = ?;`,
}
func (q Queries) Rebind(d Dialect) Queries {
return Queries{
CreateUser: d.Rebind(q.CreateUser),
GetUserByID: d.Rebind(q.GetUserByID),
}
}

View file

@ -0,0 +1,38 @@
package sqlite
import (
"context"
"database/sql"
"embed"
"fmt"
"git.juancwu.dev/juancwu/pase/store"
)
//go:embed migrations/*.sql
var migrationsFS embed.FS
// Migrate applies all pending migrations to db using the SQLite dialect.
// It is safe to call repeatedly; already-applied migrations are skipped.
//
// Run from a single instance at startup. There is no advisory locking; if you
// need it, gate the call behind your own coordination primitive.
func Migrate(ctx context.Context, db *sql.DB) error {
migrations, err := store.LoadMigrations(migrationsFS, "migrations")
if err != nil {
return fmt.Errorf("pase/sqlite: load migrations: %w", err)
}
m := &store.Migrator{
DB: db,
Dialect: store.SQLiteDialect{},
Migrations: migrations,
// SQLite's TEXT timestamp convention. INTEGER PK is implicit ROWID alias.
CreateTableSQL: `CREATE TABLE IF NOT EXISTS pase_schema_migrations (
version INTEGER PRIMARY KEY,
name TEXT NOT NULL,
applied_at TEXT NOT NULL
)`,
}
return m.Migrate(ctx)
}

94
store/sqlite/store.go Normal file
View file

@ -0,0 +1,94 @@
// Package sqlite provides a SQLite-backed implementation of store.Store.
// Tested with "modernc.org/sqlite" other sqlite drivers may yield different results.
package sqlite
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
"git.juancwu.dev/juancwu/pase/store"
)
// Store is a SQLite-backed implementation of store.Store.
//
// All queries are written with ? placeholders and rebound through the dialect
// at construction time. Every method is intended to be a single round-trip;
// methods that need atomicity (e.g. ConsumeToken) use a single statement or
// an explicit transaction — never read-modify-write through Go.
type Store struct {
db *sql.DB
dialect store.Dialect
// Pre-rebound query strings. One field per query keeps the SQL text out
// of method bodies and makes it easy to audit by reading the struct.
q store.Queries
}
// NewStore wraps an existing *sql.DB. It does not own the connection pool;
// the caller is responsible for opening, configuring, and closing it.
//
// Foreign key enforcement is required; configure it on the connection (e.g.
// via DSN `?_pragma=foreign_keys(1)` for modernc.org/sqlite, or
// `?_fk=1` for mattn/go-sqlite3) before passing the *sql.DB in.
func NewStore(db *sql.DB) *Store {
d := store.SQLiteDialect{}
s := &Store{db: db, dialect: d}
s.q = store.CanonicalQueries.Rebind(d)
return s
}
// CreateUser inserts a user row. Timestamps will be overwritten if they are set.
func (s *Store) CreateUser(ctx context.Context, u *store.User) error {
now := time.Now()
u.CreatedAt = now
u.UpdatedAt = now
_, err := s.db.ExecContext(ctx, s.q.CreateUser,
u.ID, u.Email, u.EmailVerifiedAt,
u.Username, u.UsernameNormalized,
u.DisplayName, u.ProfileImageURL,
u.Status, u.StatusReason,
u.StatusChangedAt, u.StatusExpiresAt,
u.FailedLoginCount, u.LastFailedLoginAt,
u.CreatedAt, u.UpdatedAt,
)
if err != nil {
errStr := err.Error()
if strings.Contains(errStr, "pase_users.email") {
return fmt.Errorf("pase/sqlite: create user: %w", store.ErrEmailAlreadyExists)
}
if strings.Contains(errStr, "pase_users.username_normalized") {
return fmt.Errorf("pase/sqlite: create user: %w", store.ErrUsernameAlreadyExists)
}
return fmt.Errorf("pase/sqlite: create user: %w", err)
}
return nil
}
// GetUserByID returns the user with the given id.
func (s *Store) GetUserByID(ctx context.Context, id string) (*store.User, error) {
row := s.db.QueryRowContext(ctx, s.q.GetUserByID, id)
var u store.User
err := row.Scan(
&u.ID, &u.Email, &u.EmailVerifiedAt,
&u.Username, &u.UsernameNormalized, &u.DisplayName, &u.ProfileImageURL,
&u.Status, &u.StatusReason,
&u.StatusChangedAt, &u.StatusExpiresAt,
&u.FailedLoginCount, &u.LastFailedLoginAt,
&u.CreatedAt, &u.UpdatedAt,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("pase/sqlite: get user by id: %w", store.ErrUserNotFound)
}
return nil, fmt.Errorf("pase/sqlite: get user by id: %w", err)
}
return &u, nil
}

View file

@ -0,0 +1,71 @@
package sqlite_test
import (
"context"
"database/sql"
"testing"
"git.juancwu.dev/juancwu/pase/store/sqlite"
"git.juancwu.dev/juancwu/pase/store/storetest"
// modernc.org/sqlite registers the "sqlite" driver. Pure Go, no CGO.
// Imported only by tests; the sqlite package itself stays driver-agnostic.
_ "modernc.org/sqlite"
)
// newSQLiteStore opens a fresh in-memory SQLite, applies migrations, and
// returns a Store ready to be exercised by the storetest suite. Each call
// produces an isolated database — no cross-test pollution.
//
// DSN notes:
// - file::memory: form is required so the driver parses query params.
// Plain ":memory:?..." would silently ignore them.
// - _pragma=foreign_keys(1) enables ON DELETE CASCADE on this connection.
// - _time_format=sqlite makes the driver write time.Time as
// "2006-01-02 15:04:05.999999999-07:00", which Go's stdlib can read back
// via sql.NullTime.Scan.
func newSQLiteStore(t *testing.T) storetest.SuiteStore {
t.Helper()
db, err := sql.Open("sqlite", "file::memory:?_pragma=foreign_keys(1)&_time_format=sqlite")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
// In-memory databases are per-connection by default. Pin the pool to a
// single connection so subsequent calls hit the same database.
db.SetMaxOpenConns(1)
if err := sqlite.Migrate(context.Background(), db); err != nil {
t.Fatalf("migrate: %v", err)
}
return sqlite.NewStore(db)
}
// TestSQLiteStore runs the full Store contract suite against the SQLite
// implementation. Add new dialect-specific tests below; they should be
// rare — anything testable as a contract belongs in storetest.
func TestSQLiteStore(t *testing.T) {
storetest.RunSuite(t, newSQLiteStore)
}
// TestMigrate_idempotent stays here because migrations are dialect-specific
// (per-package embed.FS) and not part of the Store interface contract.
func TestMigrate_idempotent(t *testing.T) {
db, err := sql.Open("sqlite", "file::memory:?_pragma=foreign_keys(1)&_time_format=sqlite")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
db.SetMaxOpenConns(1)
ctx := context.Background()
if err := sqlite.Migrate(ctx, db); err != nil {
t.Fatalf("first Migrate: %v", err)
}
if err := sqlite.Migrate(ctx, db); err != nil {
t.Fatalf("second Migrate should succeed, got: %v", err)
}
}

View file

@ -0,0 +1,281 @@
// Package storetest is the shared contract suite for store.Store
// implementations. Every dialect (sqlite, postgres, future in-memory) wires
// a Factory to RunSuite; passing the suite is the definition of "implements
// the Store contract correctly."
//
// This package is *not* a _test package — it exports helpers so dialect
// test files can call into it. Drivers are imported only by the dialect
// test files; storetest itself stays driver-agnostic.
package storetest
import (
"context"
"database/sql"
"errors"
"testing"
"time"
"git.juancwu.dev/juancwu/pase/store"
)
// SuiteStore is the slice of store.Store that the contract suite currently
// exercises. It exists so dialect implementations don't have to satisfy the
// full Store interface before passing what's already written — the suite
// grows method-by-method, and SuiteStore grows with it.
//
// When all Store methods have at least one contract test, SuiteStore should
// equal store.Store and Factory can be retyped to return that directly.
type SuiteStore interface {
CreateUser(ctx context.Context, u *store.User) error
GetUserByID(ctx context.Context, id string) (*store.User, error)
}
// Factory returns a fresh, isolated Store. Each call to the factory must
// produce a Store with no shared state from previous calls — RunSuite calls
// the factory once per subtest. Cleanup is registered via t.Cleanup inside
// the factory.
type Factory func(t *testing.T) SuiteStore
// RunSuite executes the full Store contract against the implementation
// produced by newStore. Add new test cases here as the Store interface
// grows; every method on the interface should have at least one case.
//
// Test names are stable subtest paths so consumers can target one with
// `go test -run TestSQLiteStore/CreateUser_duplicateEmail`.
func RunSuite(t *testing.T, newStore Factory) {
t.Helper()
cases := []struct {
name string
fn func(t *testing.T, s SuiteStore)
}{
{"CreateUser_GetUserByID_roundTrip", testCreateUserGetUserByIDRoundTrip},
{"GetUserByID_notFound", testGetUserByIDNotFound},
{"CreateUser_duplicateEmail", testCreateUserDuplicateEmail},
{"CreateUser_duplicateUsernameNormalized", testCreateUserDuplicateUsernameNormalized},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
tc.fn(t, newStore(t))
})
}
}
// ---------------------------------------------------------------------------
// Test cases. Each takes a fresh Store and exercises one aspect of the
// contract. Keep cases self-contained: do not depend on order or on state
// left by a previous case.
// ---------------------------------------------------------------------------
func testCreateUserGetUserByIDRoundTrip(t *testing.T, s SuiteStore) {
ctx := context.Background()
want := FixedUser()
if err := s.CreateUser(ctx, want); err != nil {
t.Fatalf("CreateUser: %v", err)
}
got, err := s.GetUserByID(ctx, want.ID)
if err != nil {
t.Fatalf("GetUserByID: %v", err)
}
AssertUserEqual(t, got, want)
}
func testGetUserByIDNotFound(t *testing.T, s SuiteStore) {
got, err := s.GetUserByID(context.Background(), "does-not-exist")
if err == nil {
t.Fatalf("expected error, got user %+v", got)
}
if !errors.Is(err, store.ErrUserNotFound) {
t.Errorf("expected ErrUserNotFound, got: %v", err)
}
}
func testCreateUserDuplicateEmail(t *testing.T, s SuiteStore) {
ctx := context.Background()
first := FixedUser()
if err := s.CreateUser(ctx, first); err != nil {
t.Fatalf("first CreateUser: %v", err)
}
second := FixedUser()
second.ID = "01YYYYYYYYYYYYYYYYYYYYYYYY"
second.UsernameNormalized = NullString("bob")
// Email collides with `first`. Expect ErrEmailAlreadyExists.
err := s.CreateUser(ctx, second)
if err == nil {
t.Fatal("expected duplicate-email error, got nil")
}
if !errors.Is(err, store.ErrEmailAlreadyExists) {
t.Errorf("expected ErrEmailAlreadyExists, got: %v", err)
}
}
func testCreateUserDuplicateUsernameNormalized(t *testing.T, s SuiteStore) {
ctx := context.Background()
first := FixedUser()
if err := s.CreateUser(ctx, first); err != nil {
t.Fatalf("first CreateUser: %v", err)
}
second := FixedUser()
second.ID = "01ZZZZZZZZZZZZZZZZZZZZZZZZ"
second.Email = "bob@example.com"
// UsernameNormalized still "alice" — should collide.
err := s.CreateUser(ctx, second)
if err == nil {
t.Fatal("expected duplicate-username error, got nil")
}
if !errors.Is(err, store.ErrUsernameAlreadyExists) {
t.Errorf("expected ErrUsernameAlreadyExists, got: %v", err)
}
}
// ---------------------------------------------------------------------------
// Fixtures and helpers. Exported so dialect-specific tests can reuse them
// for one-off cases that don't fit into the shared suite.
// ---------------------------------------------------------------------------
// FixedUser returns a fully-populated User with deterministic values.
// Tests that mutate it should call FixedUser() per call to avoid sharing.
func FixedUser() *store.User {
now := time.Date(2026, 5, 5, 12, 0, 0, 0, time.UTC)
verified := now.Add(-time.Hour)
changed := now.Add(-2 * time.Hour)
return &store.User{
ID: "01HXXXXXXXXXXXXXXXXXXXXXXX",
Email: "alice@example.com",
EmailVerifiedAt: NullTime(verified),
Username: NullString("Alice"),
UsernameNormalized: NullString("alice"),
DisplayName: NullString("Alice A."),
ProfileImageURL: store.NullString{},
Status: store.UserStatusActive,
StatusReason: store.NullString{},
StatusChangedAt: NullTime(changed),
StatusExpiresAt: store.NullTime{},
FailedLoginCount: 0,
LastFailedLoginAt: store.NullTime{},
CreatedAt: now,
UpdatedAt: now,
}
}
// NullString constructs a valid store.NullString. Equivalent to
// `store.NullString{NullString: sql.NullString{String: s, Valid: true}}`
// but readable in fixture tables.
func NullString(s string) store.NullString {
return store.NullString{NullString: sql.NullString{String: s, Valid: true}}
}
// NullTime constructs a valid store.NullTime. See NullString.
func NullTime(t time.Time) store.NullTime {
return store.NullTime{NullTime: sql.NullTime{Time: t, Valid: true}}
}
// AssertUserEqual compares all fields of two users, reporting per-field
// errors with t.Errorf. Times use .Equal (handles location/monotonic);
// nullable types use the dedicated equality helpers.
//
// CreatedAt/UpdatedAt are not compared field-equal because the Store mints
// them on insert. They are checked for "populated and recent" instead.
func AssertUserEqual(t *testing.T, got, want *store.User) {
t.Helper()
if got == nil {
t.Fatal("got nil user")
}
if got.ID != want.ID {
t.Errorf("ID: got %q, want %q", got.ID, want.ID)
}
if got.Email != want.Email {
t.Errorf("Email: got %q, want %q", got.Email, want.Email)
}
if got.Status != want.Status {
t.Errorf("Status: got %q, want %q", got.Status, want.Status)
}
if got.FailedLoginCount != want.FailedLoginCount {
t.Errorf("FailedLoginCount: got %d, want %d", got.FailedLoginCount, want.FailedLoginCount)
}
if !NullStringEqual(got.StatusReason, want.StatusReason) {
t.Errorf("StatusReason: got %+v, want %+v", got.StatusReason, want.StatusReason)
}
if !NullStringEqual(got.Username, want.Username) {
t.Errorf("Username: got %+v, want %+v", got.Username, want.Username)
}
if !NullStringEqual(got.UsernameNormalized, want.UsernameNormalized) {
t.Errorf("UsernameNormalized: got %+v, want %+v", got.UsernameNormalized, want.UsernameNormalized)
}
if !NullStringEqual(got.DisplayName, want.DisplayName) {
t.Errorf("DisplayName: got %+v, want %+v", got.DisplayName, want.DisplayName)
}
if !NullStringEqual(got.ProfileImageURL, want.ProfileImageURL) {
t.Errorf("ProfileImageURL: got %+v, want %+v", got.ProfileImageURL, want.ProfileImageURL)
}
if !NullTimeEqual(got.EmailVerifiedAt, want.EmailVerifiedAt) {
t.Errorf("EmailVerifiedAt: got %+v, want %+v", got.EmailVerifiedAt, want.EmailVerifiedAt)
}
if !NullTimeEqual(got.StatusChangedAt, want.StatusChangedAt) {
t.Errorf("StatusChangedAt: got %+v, want %+v", got.StatusChangedAt, want.StatusChangedAt)
}
if !NullTimeEqual(got.StatusExpiresAt, want.StatusExpiresAt) {
t.Errorf("StatusExpiresAt: got %+v, want %+v", got.StatusExpiresAt, want.StatusExpiresAt)
}
if !NullTimeEqual(got.LastFailedLoginAt, want.LastFailedLoginAt) {
t.Errorf("LastFailedLoginAt: got %+v, want %+v", got.LastFailedLoginAt, want.LastFailedLoginAt)
}
// CreatedAt/UpdatedAt: store-minted, so don't compare to `want`. Just
// check they were populated and are recent. Loose bound (1 minute)
// avoids flakiness on slow CI.
now := time.Now()
if got.CreatedAt.IsZero() {
t.Error("CreatedAt: not populated")
}
if got.UpdatedAt.IsZero() {
t.Error("UpdatedAt: not populated")
}
if delta := now.Sub(got.CreatedAt); delta < 0 || delta > time.Minute {
t.Errorf("CreatedAt: not recent (delta=%s)", delta)
}
if !got.CreatedAt.Equal(got.UpdatedAt) {
t.Errorf("CreatedAt and UpdatedAt should match on insert: %v vs %v",
got.CreatedAt, got.UpdatedAt)
}
}
// NullStringEqual reports whether two store.NullString values are equal,
// treating two invalid (NULL) values as equal regardless of String content.
func NullStringEqual(a, b store.NullString) bool {
if a.Valid != b.Valid {
return false
}
if !a.Valid {
return true
}
return a.String == b.String
}
// NullTimeEqual reports whether two store.NullTime values are equal.
// Time comparison uses time.Time.Equal so location and monotonic clock
// differences don't cause spurious mismatches.
func NullTimeEqual(a, b store.NullTime) bool {
if a.Valid != b.Valid {
return false
}
if !a.Valid {
return true
}
return a.Time.Equal(b.Time)
}