feat: rewrite db actions using raw schemas and queries (sqlc)
This commit is contained in:
parent
6569a399e3
commit
66fde0a700
@ -4,23 +4,29 @@ go 1.24.1
|
||||
|
||||
require (
|
||||
github.com/caarlos0/env v3.5.0+incompatible
|
||||
github.com/go-chi/chi/v5 v5.2.1
|
||||
github.com/go-chi/jwtauth/v5 v5.3.3
|
||||
github.com/jackc/pgx/v5 v5.7.4
|
||||
github.com/rs/zerolog v1.34.0
|
||||
gorm.io/driver/postgres v1.5.11
|
||||
gorm.io/gorm v1.25.12
|
||||
github.com/wagslane/go-password-validator v0.3.0
|
||||
golang.org/x/crypto v0.36.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.7.4 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/lestrrat-go/blackmagic v1.0.2 // indirect
|
||||
github.com/lestrrat-go/httpcc v1.0.1 // indirect
|
||||
github.com/lestrrat-go/httprc v1.0.6 // indirect
|
||||
github.com/lestrrat-go/iter v1.0.2 // indirect
|
||||
github.com/lestrrat-go/jwx/v2 v2.1.4 // indirect
|
||||
github.com/lestrrat-go/option v1.0.1 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/stretchr/testify v1.10.0 // indirect
|
||||
golang.org/x/crypto v0.36.0 // indirect
|
||||
golang.org/x/sync v0.12.0 // indirect
|
||||
github.com/segmentio/asm v1.2.0 // indirect
|
||||
golang.org/x/sys v0.31.0 // indirect
|
||||
golang.org/x/text v0.23.0 // indirect
|
||||
)
|
||||
|
@ -4,7 +4,17 @@ github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSV
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
||||
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
|
||||
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||
github.com/go-chi/jwtauth/v5 v5.3.3 h1:50Uzmacu35/ZP9ER2Ht6SazwPsnLQ9LRJy6zTZJpHEo=
|
||||
github.com/go-chi/jwtauth/v5 v5.3.3/go.mod h1:O4QvPRuZLZghl9WvfVaON+ARfGzpD2PBX/QY5vUz7aQ=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
@ -13,10 +23,18 @@ github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg=
|
||||
github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k=
|
||||
github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU=
|
||||
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
|
||||
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
|
||||
github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k=
|
||||
github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo=
|
||||
github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI=
|
||||
github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4=
|
||||
github.com/lestrrat-go/jwx/v2 v2.1.4 h1:uBCMmJX8oRZStmKuMMOFb0Yh9xmEMgNJLgjuKKt4/qc=
|
||||
github.com/lestrrat-go/jwx/v2 v2.1.4/go.mod h1:nWRbDFR1ALG2Z6GJbBXzfQaYyvn751KuuyySN2yR6is=
|
||||
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
|
||||
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
@ -30,11 +48,17 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
||||
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
|
||||
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/wagslane/go-password-validator v0.3.0 h1:vfxOPzGHkz5S146HDpavl0cw1DSVP061Ry2PX0/ON6I=
|
||||
github.com/wagslane/go-password-validator v0.3.0/go.mod h1:TI1XJ6T5fRdRnHqHt14pvy1tNVnrwe7m3/f1f2fDphQ=
|
||||
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
|
||||
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
||||
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
|
||||
@ -50,7 +74,3 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314=
|
||||
gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
|
||||
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
|
||||
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
|
||||
|
@ -1,30 +0,0 @@
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE notes (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE note_versions (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
note_id UUID NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
version_number INT NOT NULL,
|
||||
content_hash TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX idx_note_version_unique ON note_versions (note_id, version_number);
|
||||
CREATE UNIQUE INDEX idx_users_username ON users(username);
|
||||
CREATE INDEX idx_note_versions_note ON note_versions(note_id);
|
||||
CREATE INDEX idx_note_versions_number ON note_versions(version_number DESC);
|
@ -1,77 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Atomically run migrations found from the given filesystem (with the format `<SERIAL_NUM>*.up.sql`).
|
||||
// Skips already applied migrations, but fails on malformed or iisconfigured entries. Notably atomic
|
||||
// execution must be utilized to automatically roll back any partially applied migrations.
|
||||
func RunMigrations(db *gorm.DB, migrationsFS fs.FS) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY
|
||||
)
|
||||
`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Query already applied migrations to prevent duplicate execution
|
||||
var applied []int
|
||||
if err := tx.Table("schema_migrations").Pluck("version", &applied).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
migrationFiles, err := fs.Glob(migrationsFS, "*.up.sql")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, f := range sortMigrations(migrationFiles) {
|
||||
version, err := strconv.Atoi(strings.Split(f, "_")[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid migration filename: %s", f)
|
||||
}
|
||||
|
||||
if slices.Contains(applied, version) {
|
||||
continue
|
||||
}
|
||||
|
||||
sql, err := fs.ReadFile(migrationsFS, f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Exec(string(sql)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Exec(`
|
||||
INSERT INTO schema_migrations (version) VALUES (?)
|
||||
`, version).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// Sort the given migration files to ascending order based on the filename integer prefix to then
|
||||
// be executed sequentially.
|
||||
func sortMigrations(files []string) []string {
|
||||
sort.Slice(files, func(i, j int) bool {
|
||||
v1, _ := strconv.Atoi(strings.Split(files[i], "_")[0])
|
||||
v2, _ := strconv.Atoi(strings.Split(files[j], "_")[0])
|
||||
return v1 < v2
|
||||
})
|
||||
return files
|
||||
}
|
@ -1,230 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUnauthorizedOrMissing = errors.New("item not found or unauthorized")
|
||||
ErrVersionNotFound = errors.New("version not found")
|
||||
ErrDeletionFailed = errors.New("deletion failed")
|
||||
)
|
||||
|
||||
type Note struct {
|
||||
ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()"`
|
||||
UserID string `gorm:"type:uuid;not null;index"`
|
||||
User User `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime"`
|
||||
Versions []NoteVersion `gorm:"foreignKey:NoteID;constraint:OnDelete:CASCADE"`
|
||||
}
|
||||
|
||||
type NoteVersion struct {
|
||||
ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()"`
|
||||
NoteID string `gorm:"type:uuid;not null;index"`
|
||||
Title string `gorm:"type:varchar(255);not null"`
|
||||
Content string `gorm:"type:text;not null"` // TOAST will automatically compress at >2KB
|
||||
VersionNumber int `gorm:"not null"`
|
||||
ContentHash string `gorm:"type:varchar(64);not null;index"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime"`
|
||||
}
|
||||
|
||||
// Helper hook that runs automatically before a new note version is created. It calculates a
|
||||
// composite SHA256 hash of the title-content combination and looks up thte valid version
|
||||
// number based on any previous entries in the table.
|
||||
func (nv *NoteVersion) BeforeCreate(tx *gorm.DB) (err error) {
|
||||
combined := nv.Title + "\n" + nv.Content
|
||||
hash := sha256.Sum256([]byte(combined))
|
||||
nv.ContentHash = hex.EncodeToString(hash[:])
|
||||
|
||||
var maxVersion int
|
||||
err = tx.Model(&NoteVersion{}).
|
||||
Where("note_id = ?", nv.NoteID).
|
||||
Select("COALESCE(MAX(version_number), 0)").
|
||||
Scan(&maxVersion).
|
||||
Error
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
nv.VersionNumber = maxVersion + 1
|
||||
return nil
|
||||
}
|
||||
|
||||
// NOTE: User ID should always be injected to the requests by a middleware (and not taken from
|
||||
// a user input).
|
||||
|
||||
// Create a new note and its initial version. Might return an error if the creation transaction
|
||||
// fails. On success returns a pointer to the newly created note object.
|
||||
func CreateNote(db *gorm.DB, userID string, title string, content string) (*Note, error) {
|
||||
note := Note{
|
||||
UserID: userID,
|
||||
}
|
||||
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(¬e).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Create(&NoteVersion{
|
||||
NoteID: note.ID,
|
||||
Title: title,
|
||||
Content: content,
|
||||
}).Error
|
||||
})
|
||||
|
||||
return ¬e, err
|
||||
}
|
||||
|
||||
// Get a list of `limit` notes belonging to the given user and their historical versions (preloaded
|
||||
// version numbers).
|
||||
func ListNotes(db *gorm.DB, userID string, page int, limit int) ([]Note, error) {
|
||||
var notes []Note
|
||||
offset := (page - 1) * limit
|
||||
|
||||
err := db.
|
||||
Preload("Versions", func(db *gorm.DB) *gorm.DB {
|
||||
return db.Order("note_versions.version_number DESC").Limit(1)
|
||||
}).
|
||||
Where("user_id = ?", userID).
|
||||
Order("created_at DESC").
|
||||
Offset(offset).
|
||||
Limit(limit).
|
||||
Find(¬es).
|
||||
Error
|
||||
|
||||
return notes, err
|
||||
}
|
||||
|
||||
// Get a single note with its preloaded version numbers.
|
||||
func GetNote(db *gorm.DB, userID string, noteID string) (*Note, error) {
|
||||
var note Note
|
||||
err := db.
|
||||
Preload("Versions", func(db *gorm.DB) *gorm.DB {
|
||||
return db.Order("note_versions.version_number DESC")
|
||||
}).
|
||||
Where("id = ? AND user_id = ?", noteID, userID).
|
||||
First(¬e).
|
||||
Error
|
||||
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrUnauthorizedOrMissing
|
||||
}
|
||||
|
||||
return ¬e, err
|
||||
}
|
||||
|
||||
// Get all the linked historical versions of the given note.
|
||||
func GetNoteVersions(db *gorm.DB, userID string, noteID string, page int, limit int) ([]NoteVersion, error) {
|
||||
var versions []NoteVersion
|
||||
|
||||
var note Note
|
||||
if err := db.
|
||||
Select("id").
|
||||
Where("id = ? AND user_id = ?", noteID, userID).
|
||||
First(¬e).
|
||||
Error; err != nil {
|
||||
return nil, ErrUnauthorizedOrMissing
|
||||
}
|
||||
|
||||
offset := (page - 1) * limit
|
||||
err := db.
|
||||
Where("note_id = ?", noteID).
|
||||
Order("version_number DESC").
|
||||
Offset(offset).
|
||||
Limit(limit).
|
||||
Find(&versions).
|
||||
Error
|
||||
|
||||
return versions, err
|
||||
}
|
||||
|
||||
// Get a specific historical version of the given note.
|
||||
func GetNoteVersion(db *gorm.DB, userID string, noteID string, versionNumber int) (*NoteVersion, error) {
|
||||
var version NoteVersion
|
||||
|
||||
// Ownership check via note
|
||||
var note Note
|
||||
if err := db.
|
||||
Select("id").
|
||||
Where("id = ? AND user_id = ?", noteID, userID).
|
||||
First(¬e).
|
||||
Error; err != nil {
|
||||
return nil, ErrUnauthorizedOrMissing
|
||||
}
|
||||
|
||||
err := db.
|
||||
Where("note_id = ? AND version_number = ?", noteID, versionNumber).
|
||||
First(&version).
|
||||
Error
|
||||
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrVersionNotFound
|
||||
}
|
||||
|
||||
return &version, err
|
||||
}
|
||||
|
||||
// Update an existing note by creating a new version. If the composite SHA256 hash of the new
|
||||
// version contents matches with an already stored version, the new version will be skipped.
|
||||
func UpdateNote(db *gorm.DB, userID string, noteID string, newTitle string, newContent string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
var note Note
|
||||
if err := tx.
|
||||
Where("id = ? AND user_id = ?", noteID, userID).
|
||||
First(¬e).
|
||||
Error; err != nil {
|
||||
return ErrUnauthorizedOrMissing
|
||||
}
|
||||
|
||||
var latest NoteVersion
|
||||
if err := tx.
|
||||
Where("note_id = ?", noteID).
|
||||
Order("version_number DESC").
|
||||
First(&latest).
|
||||
Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
combined := newTitle + "\n" + newContent
|
||||
newHash := sha256.Sum256([]byte(combined))
|
||||
if hex.EncodeToString(newHash[:]) == latest.ContentHash {
|
||||
return nil // No changes
|
||||
}
|
||||
|
||||
return tx.Create(&NoteVersion{
|
||||
NoteID: noteID,
|
||||
Title: newTitle,
|
||||
Content: newContent,
|
||||
}).Error
|
||||
})
|
||||
}
|
||||
|
||||
// Remove a note and its versions permanently (hard delete).
|
||||
func DeleteNote(db *gorm.DB, userID string, noteID string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
var note Note
|
||||
if err := tx.
|
||||
Where("id = ? AND user_id = ?", noteID, userID).
|
||||
First(¬e).
|
||||
Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrUnauthorizedOrMissing
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete note (cascades to versions)
|
||||
if err := tx.Delete(¬e).Error; err != nil {
|
||||
return ErrDeletionFailed
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
@ -1,104 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()"`
|
||||
Username string `gorm:"unique;not null;index"`
|
||||
PasswordHash string `gorm:"not null"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime"`
|
||||
Notes []Note `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE"`
|
||||
}
|
||||
|
||||
var (
|
||||
ErrUserNotFound = errors.New("user not found")
|
||||
ErrUsernameTaken = errors.New("username is not available")
|
||||
ErrInvalidCredentials = errors.New("invalid credentials")
|
||||
)
|
||||
|
||||
// Create a new user using the given credentials (password hashed with `bcrypt`).
|
||||
func CreateUser(db *gorm.DB, username string, password string) (*User, error) {
|
||||
// Username availability
|
||||
var count int64
|
||||
db.Model(&User{}).Where("username = ?", username).Count(&count)
|
||||
if count > 0 {
|
||||
return nil, ErrUsernameTaken
|
||||
}
|
||||
|
||||
// Password hashing, max length (72 bytes) validated in the handler layer
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user := User{
|
||||
Username: username,
|
||||
PasswordHash: string(hash),
|
||||
}
|
||||
|
||||
err = db.Create(&user).Error
|
||||
return &user, err
|
||||
}
|
||||
|
||||
// Get a user with their UUID.
|
||||
func GetUserByID(db *gorm.DB, userID string) (*User, error) {
|
||||
var user User
|
||||
err := db.First(&user, "id = ?", userID).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
return &user, err
|
||||
}
|
||||
|
||||
// Get a user with their username.
|
||||
func GetUserByUsername(db *gorm.DB, username string) (*User, error) {
|
||||
var user User
|
||||
err := db.First(&user, "username = ?", username).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
return &user, err
|
||||
}
|
||||
|
||||
// Verify the given credentials by fetching the hashed password from the database and comparing it
|
||||
// with the given one using `bcrypt`.
|
||||
func VerifyUserCredentials(db *gorm.DB, username string, password string) (*User, error) {
|
||||
user, err := GetUserByUsername(db, username)
|
||||
if err != nil {
|
||||
return nil, err // ErrUserNotFound
|
||||
}
|
||||
|
||||
err = bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password))
|
||||
if err != nil {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// Update the password of the given user (identified with UUID).
|
||||
func UpdatePassword(db *gorm.DB, userID string, newPassword string) error {
|
||||
// Once again (length) validation should be done in the handler layer
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return db.Model(&User{}).
|
||||
Where("id = ?", userID).
|
||||
Update("password_hash", string(hash)).
|
||||
Error
|
||||
}
|
||||
|
||||
// Remove a user and their notes (and notes' versions, hard delete with cascade).
|
||||
func DeleteUser(db *gorm.DB, userID string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
return tx.Delete(&User{}, "id = ?", userID).Error
|
||||
})
|
||||
}
|
32
server/pkg/db/db.go
Normal file
32
server/pkg/db/db.go
Normal file
@ -0,0 +1,32 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.28.0
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
type DBTX interface {
|
||||
Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error)
|
||||
Query(context.Context, string, ...interface{}) (pgx.Rows, error)
|
||||
QueryRow(context.Context, string, ...interface{}) pgx.Row
|
||||
}
|
||||
|
||||
func New(db DBTX) *Queries {
|
||||
return &Queries{db: db}
|
||||
}
|
||||
|
||||
type Queries struct {
|
||||
db DBTX
|
||||
}
|
||||
|
||||
func (q *Queries) WithTx(tx pgx.Tx) *Queries {
|
||||
return &Queries{
|
||||
db: tx,
|
||||
}
|
||||
}
|
39
server/pkg/db/models.go
Normal file
39
server/pkg/db/models.go
Normal file
@ -0,0 +1,39 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.28.0
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
type Note struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type NoteVersion struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
NoteID pgtype.UUID `json:"note_id"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
VersionNumber int32 `json:"version_number"`
|
||||
ContentHash string `json:"content_hash"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type SchemaMigration struct {
|
||||
Version int64 `json:"version"`
|
||||
AppliedAt pgtype.Timestamptz `json:"applied_at"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Username string `json:"username"`
|
||||
PasswordHash string `json:"password_hash"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
132
server/pkg/db/note_versions.sql.go
Normal file
132
server/pkg/db/note_versions.sql.go
Normal file
@ -0,0 +1,132 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.28.0
|
||||
// source: note_versions.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const createNoteVersion = `-- name: CreateNoteVersion :one
|
||||
INSERT INTO note_versions (note_id, title, content, version_number, content_hash)
|
||||
VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
(SELECT COALESCE(MAX(version_number), 0) + 1 FROM note_versions WHERE note_id = $1),
|
||||
encode(sha256($2::bytea || '\n'::bytea || $3::bytea), 'hex')
|
||||
)
|
||||
RETURNING id, note_id, title, content, version_number, content_hash, created_at
|
||||
`
|
||||
|
||||
type CreateNoteVersionParams struct {
|
||||
NoteID pgtype.UUID `json:"note_id"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateNoteVersion(ctx context.Context, arg CreateNoteVersionParams) (NoteVersion, error) {
|
||||
row := q.db.QueryRow(ctx, createNoteVersion, arg.NoteID, arg.Title, arg.Content)
|
||||
var i NoteVersion
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.NoteID,
|
||||
&i.Title,
|
||||
&i.Content,
|
||||
&i.VersionNumber,
|
||||
&i.ContentHash,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const findDuplicateContent = `-- name: FindDuplicateContent :one
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM note_versions
|
||||
WHERE note_id = $1
|
||||
AND content_hash = encode(sha256($2::bytea || '\n'::bytea || $3::bytea), 'hex')
|
||||
)
|
||||
`
|
||||
|
||||
type FindDuplicateContentParams struct {
|
||||
NoteID pgtype.UUID `json:"note_id"`
|
||||
Column2 []byte `json:"column_2"`
|
||||
Column3 []byte `json:"column_3"`
|
||||
}
|
||||
|
||||
func (q *Queries) FindDuplicateContent(ctx context.Context, arg FindDuplicateContentParams) (bool, error) {
|
||||
row := q.db.QueryRow(ctx, findDuplicateContent, arg.NoteID, arg.Column2, arg.Column3)
|
||||
var exists bool
|
||||
err := row.Scan(&exists)
|
||||
return exists, err
|
||||
}
|
||||
|
||||
const getNoteVersion = `-- name: GetNoteVersion :one
|
||||
SELECT id, note_id, title, content, version_number, content_hash, created_at FROM note_versions
|
||||
WHERE note_id = $1 AND version_number = $2 LIMIT 1
|
||||
`
|
||||
|
||||
type GetNoteVersionParams struct {
|
||||
NoteID pgtype.UUID `json:"note_id"`
|
||||
VersionNumber int32 `json:"version_number"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetNoteVersion(ctx context.Context, arg GetNoteVersionParams) (NoteVersion, error) {
|
||||
row := q.db.QueryRow(ctx, getNoteVersion, arg.NoteID, arg.VersionNumber)
|
||||
var i NoteVersion
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.NoteID,
|
||||
&i.Title,
|
||||
&i.Content,
|
||||
&i.VersionNumber,
|
||||
&i.ContentHash,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getNoteVersions = `-- name: GetNoteVersions :many
|
||||
SELECT id, note_id, title, content, version_number, content_hash, created_at FROM note_versions
|
||||
WHERE note_id = $1
|
||||
ORDER BY version_number DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
`
|
||||
|
||||
type GetNoteVersionsParams struct {
|
||||
NoteID pgtype.UUID `json:"note_id"`
|
||||
Limit int32 `json:"limit"`
|
||||
Offset int32 `json:"offset"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetNoteVersions(ctx context.Context, arg GetNoteVersionsParams) ([]NoteVersion, error) {
|
||||
rows, err := q.db.Query(ctx, getNoteVersions, arg.NoteID, arg.Limit, arg.Offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []NoteVersion
|
||||
for rows.Next() {
|
||||
var i NoteVersion
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.NoteID,
|
||||
&i.Title,
|
||||
&i.Content,
|
||||
&i.VersionNumber,
|
||||
&i.ContentHash,
|
||||
&i.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
105
server/pkg/db/notes.sql.go
Normal file
105
server/pkg/db/notes.sql.go
Normal file
@ -0,0 +1,105 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.28.0
|
||||
// source: notes.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const createNote = `-- name: CreateNote :one
|
||||
INSERT INTO notes (user_id)
|
||||
VALUES ($1)
|
||||
RETURNING id, user_id, created_at, updated_at
|
||||
`
|
||||
|
||||
func (q *Queries) CreateNote(ctx context.Context, userID pgtype.UUID) (Note, error) {
|
||||
row := q.db.QueryRow(ctx, createNote, userID)
|
||||
var i Note
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const deleteNote = `-- name: DeleteNote :exec
|
||||
DELETE FROM notes
|
||||
WHERE id = $1 AND user_id = $2
|
||||
`
|
||||
|
||||
type DeleteNoteParams struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) DeleteNote(ctx context.Context, arg DeleteNoteParams) error {
|
||||
_, err := q.db.Exec(ctx, deleteNote, arg.ID, arg.UserID)
|
||||
return err
|
||||
}
|
||||
|
||||
const getNote = `-- name: GetNote :one
|
||||
SELECT id, user_id, created_at, updated_at FROM notes
|
||||
WHERE id = $1 AND user_id = $2 LIMIT 1
|
||||
`
|
||||
|
||||
type GetNoteParams struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetNote(ctx context.Context, arg GetNoteParams) (Note, error) {
|
||||
row := q.db.QueryRow(ctx, getNote, arg.ID, arg.UserID)
|
||||
var i Note
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listNotes = `-- name: ListNotes :many
|
||||
SELECT id, user_id, created_at, updated_at FROM notes
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
`
|
||||
|
||||
type ListNotesParams struct {
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
Limit int32 `json:"limit"`
|
||||
Offset int32 `json:"offset"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListNotes(ctx context.Context, arg ListNotesParams) ([]Note, error) {
|
||||
rows, err := q.db.Query(ctx, listNotes, arg.UserID, arg.Limit, arg.Offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []Note
|
||||
for rows.Next() {
|
||||
var i Note
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
98
server/pkg/db/users.sql.go
Normal file
98
server/pkg/db/users.sql.go
Normal file
@ -0,0 +1,98 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.28.0
|
||||
// source: users.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const createUser = `-- name: CreateUser :one
|
||||
INSERT INTO users (username, password_hash)
|
||||
VALUES ($1, $2)
|
||||
RETURNING id, username, password_hash, created_at, updated_at
|
||||
`
|
||||
|
||||
type CreateUserParams struct {
|
||||
Username string `json:"username"`
|
||||
PasswordHash string `json:"password_hash"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) {
|
||||
row := q.db.QueryRow(ctx, createUser, arg.Username, arg.PasswordHash)
|
||||
var i User
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Username,
|
||||
&i.PasswordHash,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const deleteUser = `-- name: DeleteUser :exec
|
||||
DELETE FROM users
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteUser(ctx context.Context, id pgtype.UUID) error {
|
||||
_, err := q.db.Exec(ctx, deleteUser, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const getUserByID = `-- name: GetUserByID :one
|
||||
SELECT id, username, password_hash, created_at, updated_at FROM users
|
||||
WHERE id = $1 LIMIT 1
|
||||
`
|
||||
|
||||
func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (User, error) {
|
||||
row := q.db.QueryRow(ctx, getUserByID, id)
|
||||
var i User
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Username,
|
||||
&i.PasswordHash,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getUserByUsername = `-- name: GetUserByUsername :one
|
||||
SELECT id, username, password_hash, created_at, updated_at FROM users
|
||||
WHERE username = $1 LIMIT 1
|
||||
`
|
||||
|
||||
func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User, error) {
|
||||
row := q.db.QueryRow(ctx, getUserByUsername, username)
|
||||
var i User
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Username,
|
||||
&i.PasswordHash,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const updatePassword = `-- name: UpdatePassword :exec
|
||||
UPDATE users
|
||||
SET password_hash = $2, updated_at = NOW()
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
type UpdatePasswordParams struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
PasswordHash string `json:"password_hash"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdatePassword(ctx context.Context, arg UpdatePasswordParams) error {
|
||||
_, err := q.db.Exec(ctx, updatePassword, arg.ID, arg.PasswordHash)
|
||||
return err
|
||||
}
|
74
server/pkg/migrate/migrate.go
Normal file
74
server/pkg/migrate/migrate.go
Normal file
@ -0,0 +1,74 @@
|
||||
// pkg/migrate/migrate.go
|
||||
package migrate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
func Run(ctx context.Context, conn *pgx.Conn, migrationsFS embed.FS) error {
|
||||
// Get already applied migrations
|
||||
rows, _ := conn.Query(ctx, "SELECT version FROM schema_migrations")
|
||||
defer rows.Close()
|
||||
|
||||
applied := make(map[int64]bool)
|
||||
for rows.Next() {
|
||||
var version int64
|
||||
if err := rows.Scan(&version); err != nil {
|
||||
return err
|
||||
}
|
||||
applied[version] = true
|
||||
}
|
||||
|
||||
files, err := migrationsFS.ReadDir("migrations")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Apply the migrations sequentially based on their ordinal number
|
||||
for _, f := range sortMigrations(files) {
|
||||
version, err := strconv.ParseInt(strings.Split(f.Name(), "_")[0], 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid migration name: %s", f.Name())
|
||||
}
|
||||
|
||||
if applied[version] {
|
||||
continue
|
||||
}
|
||||
|
||||
// Run migration
|
||||
sql, err := migrationsFS.ReadFile("migrations/" + f.Name())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := conn.Exec(ctx, string(sql)); err != nil {
|
||||
return fmt.Errorf("migration %d failed: %w", version, err)
|
||||
}
|
||||
|
||||
if _, err := conn.Exec(ctx,
|
||||
"INSERT INTO schema_migrations (version) VALUES ($1)", version,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Sort the migration files based on their ordinal number prefix.
|
||||
func sortMigrations(files []fs.DirEntry) []fs.DirEntry {
|
||||
sort.Slice(files, func(i, j int) bool {
|
||||
v1, _ := strconv.ParseInt(strings.Split(files[i].Name(), "_")[0], 10, 64)
|
||||
v2, _ := strconv.ParseInt(strings.Split(files[j].Name(), "_")[0], 10, 64)
|
||||
return v1 < v2
|
||||
})
|
||||
return files
|
||||
}
|
37
server/sql/migrations/0001_initial.up.sql
Normal file
37
server/sql/migrations/0001_initial.up.sql
Normal file
@ -0,0 +1,37 @@
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version BIGINT PRIMARY KEY,
|
||||
applied_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notes (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS note_versions (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
note_id UUID NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
version_number INT NOT NULL,
|
||||
content_hash TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_note_version_unique ON note_versions(note_id, version_number);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||
CREATE INDEX IF NOT EXISTS idx_note_versions_note ON note_versions(note_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_note_versions_number ON note_versions(version_number DESC);
|
||||
|
27
server/sql/queries/note_versions.sql
Normal file
27
server/sql/queries/note_versions.sql
Normal file
@ -0,0 +1,27 @@
|
||||
-- name: CreateNoteVersion :one
|
||||
INSERT INTO note_versions (note_id, title, content, version_number, content_hash)
|
||||
VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
(SELECT COALESCE(MAX(version_number), 0) + 1 FROM note_versions WHERE note_id = $1),
|
||||
encode(sha256($2::bytea || '\n'::bytea || $3::bytea), 'hex')
|
||||
)
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetNoteVersions :many
|
||||
SELECT * FROM note_versions
|
||||
WHERE note_id = $1
|
||||
ORDER BY version_number DESC
|
||||
LIMIT $2 OFFSET $3;
|
||||
|
||||
-- name: GetNoteVersion :one
|
||||
SELECT * FROM note_versions
|
||||
WHERE note_id = $1 AND version_number = $2 LIMIT 1;
|
||||
|
||||
-- name: FindDuplicateContent :one
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM note_versions
|
||||
WHERE note_id = $1
|
||||
AND content_hash = encode(sha256($2::bytea || '\n'::bytea || $3::bytea), 'hex')
|
||||
);
|
18
server/sql/queries/notes.sql
Normal file
18
server/sql/queries/notes.sql
Normal file
@ -0,0 +1,18 @@
|
||||
-- name: CreateNote :one
|
||||
INSERT INTO notes (user_id)
|
||||
VALUES ($1)
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetNote :one
|
||||
SELECT * FROM notes
|
||||
WHERE id = $1 AND user_id = $2 LIMIT 1;
|
||||
|
||||
-- name: ListNotes :many
|
||||
SELECT * FROM notes
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2 OFFSET $3;
|
||||
|
||||
-- name: DeleteNote :exec
|
||||
DELETE FROM notes
|
||||
WHERE id = $1 AND user_id = $2;
|
21
server/sql/queries/users.sql
Normal file
21
server/sql/queries/users.sql
Normal file
@ -0,0 +1,21 @@
|
||||
-- name: CreateUser :one
|
||||
INSERT INTO users (username, password_hash)
|
||||
VALUES ($1, $2)
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetUserByID :one
|
||||
SELECT * FROM users
|
||||
WHERE id = $1 LIMIT 1;
|
||||
|
||||
-- name: GetUserByUsername :one
|
||||
SELECT * FROM users
|
||||
WHERE username = $1 LIMIT 1;
|
||||
|
||||
-- name: UpdatePassword :exec
|
||||
UPDATE users
|
||||
SET password_hash = $2, updated_at = NOW()
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: DeleteUser :exec
|
||||
DELETE FROM users
|
||||
WHERE id = $1;
|
12
server/sql/sqlc.yaml
Normal file
12
server/sql/sqlc.yaml
Normal file
@ -0,0 +1,12 @@
|
||||
version: "2"
|
||||
|
||||
sql:
|
||||
- engine: "postgresql"
|
||||
queries: "./queries/"
|
||||
schema: "./migrations/"
|
||||
gen:
|
||||
go:
|
||||
package: "db"
|
||||
out: "../pkg/db"
|
||||
sql_package: "pgx/v5"
|
||||
emit_json_tags: true
|
Loading…
x
Reference in New Issue
Block a user