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 (
|
require (
|
||||||
github.com/caarlos0/env v3.5.0+incompatible
|
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
|
github.com/rs/zerolog v1.34.0
|
||||||
gorm.io/driver/postgres v1.5.11
|
github.com/wagslane/go-password-validator v0.3.0
|
||||||
gorm.io/gorm v1.25.12
|
golang.org/x/crypto v0.36.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
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/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/pgx/v5 v5.7.4 // indirect
|
github.com/lestrrat-go/blackmagic v1.0.2 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/lestrrat-go/httpcc v1.0.1 // indirect
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/lestrrat-go/httprc v1.0.6 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // 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-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/stretchr/testify v1.10.0 // indirect
|
github.com/segmentio/asm v1.2.0 // indirect
|
||||||
golang.org/x/crypto v0.36.0 // indirect
|
|
||||||
golang.org/x/sync v0.12.0 // indirect
|
|
||||||
golang.org/x/sys v0.31.0 // indirect
|
golang.org/x/sys v0.31.0 // indirect
|
||||||
golang.org/x/text v0.23.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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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/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 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
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/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 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
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/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k=
|
||||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU=
|
||||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
|
||||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
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.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 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
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/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 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||||
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
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/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.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.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 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
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 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
|
||||||
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
||||||
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
|
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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
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