feat: rewrite db actions using raw schemas and queries (sqlc)

This commit is contained in:
ae 2025-03-28 01:52:56 +02:00
parent 6569a399e3
commit 66fde0a700
Signed by: ae
GPG Key ID: 995EFD5C1B532B3E
17 changed files with 638 additions and 458 deletions

View File

@ -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
)

View File

@ -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=

View File

@ -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);

View File

@ -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
}

View File

@ -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(&note).Error; err != nil {
return err
}
return tx.Create(&NoteVersion{
NoteID: note.ID,
Title: title,
Content: content,
}).Error
})
return &note, 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(&notes).
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(&note).
Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrUnauthorizedOrMissing
}
return &note, 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(&note).
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(&note).
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(&note).
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(&note).
Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrUnauthorizedOrMissing
}
return err
}
// Delete note (cascades to versions)
if err := tx.Delete(&note).Error; err != nil {
return ErrDeletionFailed
}
return nil
})
}

View File

@ -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
View 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
View 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"`
}

View 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
View 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
}

View 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
}

View 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
}

View 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);

View 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')
);

View 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;

View 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
View 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