Added a database

This commit is contained in:
Leon Mika 2026-02-19 22:29:44 +11:00
parent ebaec3d296
commit 8136655336
35 changed files with 925 additions and 134 deletions

View file

@ -1,37 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: users.sql
package sql
import (
"context"
)
const insertUserByUsername = `-- name: InsertUserByUsername :one
INSERT INTO users (username, password) VALUES (?, ?) RETURNING id
`
type InsertUserByUsernameParams struct {
Username string
Password string
}
func (q *Queries) InsertUserByUsername(ctx context.Context, arg InsertUserByUsernameParams) (int64, error) {
row := q.db.QueryRowContext(ctx, insertUserByUsername, arg.Username, arg.Password)
var id int64
err := row.Scan(&id)
return id, err
}
const selectUserByUsername = `-- name: SelectUserByUsername :one
SELECT id, username, password FROM users WHERE username = ?
`
func (q *Queries) SelectUserByUsername(ctx context.Context, username string) (User, error) {
row := q.db.QueryRowContext(ctx, selectUserByUsername, username)
var i User
err := row.Scan(&i.ID, &i.Username, &i.Password)
return i, err
}

View file

@ -1,8 +1,8 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// sqlc v1.28.0
package sql
package sqlgen
import (
"context"

View file

@ -1,8 +1,8 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// sqlc v1.28.0
package sql
package sqlgen
type Post struct {
ID int64
@ -16,12 +16,12 @@ type Post struct {
}
type PublishTarget struct {
ID int64
SiteID int64
PublishTargetType int64
BaseUrl string
TargetSiteID string
TargetPublishKey string
ID int64
SiteID int64
TargetType int64
BaseUrl string
TargetRef string
TargetKey string
}
type Site struct {

View file

@ -1,9 +1,9 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// sqlc v1.28.0
// source: posts.sql
package sql
package sqlgen
import (
"context"

View file

@ -1,9 +1,9 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// sqlc v1.28.0
// source: pubtargets.sql
package sql
package sqlgen
import (
"context"
@ -12,29 +12,29 @@ import (
const insertPublishTarget = `-- name: InsertPublishTarget :one
INSERT INTO publish_targets (
site_id,
publish_target_type,
target_type,
base_url,
target_site_id,
target_publish_key
target_ref,
target_key
) VALUES (?, ?, ?, ?, ?)
RETURNING id
`
type InsertPublishTargetParams struct {
SiteID int64
PublishTargetType int64
BaseUrl string
TargetSiteID string
TargetPublishKey string
SiteID int64
TargetType int64
BaseUrl string
TargetRef string
TargetKey string
}
func (q *Queries) InsertPublishTarget(ctx context.Context, arg InsertPublishTargetParams) (int64, error) {
row := q.db.QueryRowContext(ctx, insertPublishTarget,
arg.SiteID,
arg.PublishTargetType,
arg.TargetType,
arg.BaseUrl,
arg.TargetSiteID,
arg.TargetPublishKey,
arg.TargetRef,
arg.TargetKey,
)
var id int64
err := row.Scan(&id)
@ -42,7 +42,7 @@ func (q *Queries) InsertPublishTarget(ctx context.Context, arg InsertPublishTarg
}
const selectPublishTargetsOfSite = `-- name: SelectPublishTargetsOfSite :many
SELECT id, site_id, publish_target_type, base_url, target_site_id, target_publish_key FROM publish_targets WHERE site_id = ?
SELECT id, site_id, target_type, base_url, target_ref, target_key FROM publish_targets WHERE site_id = ?
`
func (q *Queries) SelectPublishTargetsOfSite(ctx context.Context, siteID int64) ([]PublishTarget, error) {
@ -57,10 +57,10 @@ func (q *Queries) SelectPublishTargetsOfSite(ctx context.Context, siteID int64)
if err := rows.Scan(
&i.ID,
&i.SiteID,
&i.PublishTargetType,
&i.TargetType,
&i.BaseUrl,
&i.TargetSiteID,
&i.TargetPublishKey,
&i.TargetRef,
&i.TargetKey,
); err != nil {
return nil, err
}

View file

@ -1,9 +1,9 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// sqlc v1.28.0
// source: sites.sql
package sql
package sqlgen
import (
"context"
@ -31,6 +31,22 @@ func (q *Queries) InsertSite(ctx context.Context, arg InsertSiteParams) (int64,
return id, err
}
const selectSiteByID = `-- name: SelectSiteByID :one
SELECT id, owner_id, title, tagline FROM sites WHERE id = ?
`
func (q *Queries) SelectSiteByID(ctx context.Context, id int64) (Site, error) {
row := q.db.QueryRowContext(ctx, selectSiteByID, id)
var i Site
err := row.Scan(
&i.ID,
&i.OwnerID,
&i.Title,
&i.Tagline,
)
return i, err
}
const selectSitesOwnedByUser = `-- name: SelectSitesOwnedByUser :many
SELECT id, owner_id, title, tagline FROM sites WHERE owner_id = ?
`

View file

@ -0,0 +1,52 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// source: users.sql
package sqlgen
import (
"context"
)
const insertUser = `-- name: InsertUser :one
INSERT INTO users (username, password) VALUES (?, ?) RETURNING id
`
type InsertUserParams struct {
Username string
Password string
}
func (q *Queries) InsertUser(ctx context.Context, arg InsertUserParams) (int64, error) {
row := q.db.QueryRowContext(ctx, insertUser, arg.Username, arg.Password)
var id int64
err := row.Scan(&id)
return id, err
}
const selectUserByUsername = `-- name: SelectUserByUsername :one
SELECT id, username, password FROM users WHERE username = ?
`
func (q *Queries) SelectUserByUsername(ctx context.Context, username string) (User, error) {
row := q.db.QueryRowContext(ctx, selectUserByUsername, username)
var i User
err := row.Scan(&i.ID, &i.Username, &i.Password)
return i, err
}
const updateUser = `-- name: UpdateUser :exec
UPDATE users SET username = ?, password = ? WHERE id = ?
`
type UpdateUserParams struct {
Username string
Password string
ID int64
}
func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) error {
_, err := q.db.ExecContext(ctx, updateUser, arg.Username, arg.Password, arg.ID)
return err
}

53
providers/db/posts.go Normal file
View file

@ -0,0 +1,53 @@
package db
import (
"context"
"time"
"lmika.dev/lmika/weiro/models"
"lmika.dev/lmika/weiro/providers/db/gen/sqlgen"
)
func (db *Provider) SelectPostsOfSite(ctx context.Context, siteID int64) ([]*models.Post, error) {
rows, err := db.queries.SelectPostsOfSite(ctx, siteID)
if err != nil {
return nil, err
}
posts := make([]*models.Post, len(rows))
for i, row := range rows {
posts[i] = &models.Post{
ID: row.ID,
SiteID: row.SiteID,
GUID: row.Guid,
Title: row.Title,
Body: row.Body,
Slug: row.Slug,
CreatedAt: time.Unix(row.CreatedAt, 0).UTC(),
PublishedAt: time.Unix(row.PublishedAt, 0).UTC(),
}
}
return posts, nil
}
func (db *Provider) SavePost(ctx context.Context, post *models.Post) error {
if post.ID == 0 {
newID, err := db.queries.InsertPost(ctx, sqlgen.InsertPostParams{
SiteID: post.SiteID,
Guid: post.GUID,
Title: post.Title,
Body: post.Body,
Slug: post.Slug,
CreatedAt: post.CreatedAt.Unix(),
PublishedAt: post.PublishedAt.Unix(),
})
if err != nil {
return err
}
post.ID = newID
return nil
}
// No update query defined in sqlgen yet
return nil
}

View file

@ -5,14 +5,14 @@ import (
"database/sql"
"github.com/Southclaws/fault"
"github.com/lmika/blogging-tools/providers/db/sqlc/maindbq"
"github.com/lmika/blogging-tools/sql/maindb/schema"
"lmika.dev/lmika/weiro/providers/db/gen/sqlgen"
"lmika.dev/lmika/weiro/sql/schema"
migration "lmika.dev/pkg/litemigrate"
)
type Provider struct {
drvr *sql.DB
queries *maindbq.Queries
queries *sqlgen.Queries
}
func New(dbFile string) (*Provider, error) {
@ -31,7 +31,7 @@ func New(dbFile string) (*Provider, error) {
return &Provider{
drvr: drvr,
queries: maindbq.New(drvr),
queries: sqlgen.New(drvr),
}, nil
}

View file

@ -0,0 +1,306 @@
package db_test
import (
"context"
"encoding/base64"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"lmika.dev/lmika/weiro/models"
"lmika.dev/lmika/weiro/providers/db"
_ "modernc.org/sqlite"
)
func newTestDB(t *testing.T) *db.Provider {
t.Helper()
dbFile := filepath.Join(t.TempDir(), "test.db")
p, err := db.New(dbFile)
require.NoError(t, err)
t.Cleanup(func() { p.Close() })
return p
}
func TestProvider_Users(t *testing.T) {
ctx := context.Background()
p := newTestDB(t)
t.Run("save and select user", func(t *testing.T) {
user := &models.User{
Username: "alice",
PasswordHashed: []byte("hashed-password"),
}
err := p.SaveUser(ctx, user)
require.NoError(t, err)
assert.NotZero(t, user.ID)
got, err := p.SelectUserByUsername(ctx, "alice")
require.NoError(t, err)
assert.Equal(t, user.ID, got.ID)
assert.Equal(t, "alice", got.Username)
assert.Equal(t, []byte("hashed-password"), got.PasswordHashed)
})
t.Run("update user", func(t *testing.T) {
user := &models.User{
Username: "bob",
PasswordHashed: []byte("old-password"),
}
err := p.SaveUser(ctx, user)
require.NoError(t, err)
user.Username = "bob"
user.PasswordHashed = []byte("new-password")
err = p.SaveUser(ctx, user)
require.NoError(t, err)
got, err := p.SelectUserByUsername(ctx, "bob")
require.NoError(t, err)
assert.Equal(t, []byte("new-password"), got.PasswordHashed)
})
}
func TestProvider_Sites(t *testing.T) {
ctx := context.Background()
p := newTestDB(t)
// Create a user first (sites need an owner)
user := &models.User{
Username: "testuser",
PasswordHashed: []byte("password"),
}
require.NoError(t, p.SaveUser(ctx, user))
t.Run("save and select sites", func(t *testing.T) {
site := &models.Site{
OwnerID: user.ID,
Title: "My Blog",
Tagline: "A test blog",
}
err := p.SaveSite(ctx, site)
require.NoError(t, err)
assert.NotZero(t, site.ID)
sites, err := p.SelectSitesOwnedByUser(ctx, user.ID)
require.NoError(t, err)
require.Len(t, sites, 1)
assert.Equal(t, site.ID, sites[0].ID)
assert.Equal(t, user.ID, sites[0].OwnerID)
assert.Equal(t, "My Blog", sites[0].Title)
assert.Equal(t, "A test blog", sites[0].Tagline)
})
t.Run("select site by id", func(t *testing.T) {
site := &models.Site{
OwnerID: user.ID,
Title: "Lookup Blog",
Tagline: "Find me by ID",
}
require.NoError(t, p.SaveSite(ctx, site))
got, err := p.SelectSiteByID(ctx, site.ID)
require.NoError(t, err)
assert.Equal(t, site.ID, got.ID)
assert.Equal(t, user.ID, got.OwnerID)
assert.Equal(t, "Lookup Blog", got.Title)
assert.Equal(t, "Find me by ID", got.Tagline)
})
t.Run("select sites for user with no sites", func(t *testing.T) {
otherUser := &models.User{
Username: "otheruser",
PasswordHashed: []byte("password"),
}
require.NoError(t, p.SaveUser(ctx, otherUser))
sites, err := p.SelectSitesOwnedByUser(ctx, otherUser.ID)
require.NoError(t, err)
assert.Empty(t, sites)
})
}
func TestProvider_Posts(t *testing.T) {
ctx := context.Background()
p := newTestDB(t)
// Create user and site
user := &models.User{
Username: "testuser",
PasswordHashed: []byte("password"),
}
require.NoError(t, p.SaveUser(ctx, user))
site := &models.Site{
OwnerID: user.ID,
Title: "My Blog",
Tagline: "A test blog",
}
require.NoError(t, p.SaveSite(ctx, site))
t.Run("save and select posts", func(t *testing.T) {
now := time.Date(2026, 2, 19, 12, 0, 0, 0, time.UTC)
post := &models.Post{
SiteID: site.ID,
GUID: "post-001",
Title: "First Post",
Body: "Hello world",
Slug: "/2026/02/19/first-post",
CreatedAt: now,
PublishedAt: now,
}
err := p.SavePost(ctx, post)
require.NoError(t, err)
assert.NotZero(t, post.ID)
posts, err := p.SelectPostsOfSite(ctx, site.ID)
require.NoError(t, err)
require.Len(t, posts, 1)
assert.Equal(t, post.ID, posts[0].ID)
assert.Equal(t, site.ID, posts[0].SiteID)
assert.Equal(t, "post-001", posts[0].GUID)
assert.Equal(t, "First Post", posts[0].Title)
assert.Equal(t, "Hello world", posts[0].Body)
assert.Equal(t, "/2026/02/19/first-post", posts[0].Slug)
assert.Equal(t, now, posts[0].CreatedAt)
assert.Equal(t, now, posts[0].PublishedAt)
})
t.Run("posts ordered by created_at desc", func(t *testing.T) {
// Create a second site to isolate this test
site2 := &models.Site{
OwnerID: user.ID,
Title: "Second Blog",
Tagline: "",
}
require.NoError(t, p.SaveSite(ctx, site2))
earlier := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
later := time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC)
post1 := &models.Post{
SiteID: site2.ID,
GUID: "old-post",
Title: "Old Post",
Body: "old",
Slug: "/old",
CreatedAt: earlier,
PublishedAt: earlier,
}
post2 := &models.Post{
SiteID: site2.ID,
GUID: "new-post",
Title: "New Post",
Body: "new",
Slug: "/new",
CreatedAt: later,
PublishedAt: later,
}
require.NoError(t, p.SavePost(ctx, post1))
require.NoError(t, p.SavePost(ctx, post2))
posts, err := p.SelectPostsOfSite(ctx, site2.ID)
require.NoError(t, err)
require.Len(t, posts, 2)
assert.Equal(t, "New Post", posts[0].Title)
assert.Equal(t, "Old Post", posts[1].Title)
})
t.Run("select posts for site with no posts", func(t *testing.T) {
emptySite := &models.Site{
OwnerID: user.ID,
Title: "Empty Blog",
Tagline: "",
}
require.NoError(t, p.SaveSite(ctx, emptySite))
posts, err := p.SelectPostsOfSite(ctx, emptySite.ID)
require.NoError(t, err)
assert.Empty(t, posts)
})
}
func TestProvider_PublishTargets(t *testing.T) {
ctx := context.Background()
p := newTestDB(t)
// Create user and site
user := &models.User{
Username: "testuser",
PasswordHashed: []byte("password"),
}
require.NoError(t, p.SaveUser(ctx, user))
site := &models.Site{
OwnerID: user.ID,
Title: "My Blog",
Tagline: "A test blog",
}
require.NoError(t, p.SaveSite(ctx, site))
t.Run("save and select publish targets", func(t *testing.T) {
target := &models.SitePublishTarget{
SiteID: site.ID,
TargetType: models.PublishTargetTypeNetlify,
BaseURL: "https://example.netlify.app",
TargetRef: "netlify-site-123",
TargetKey: "secret-key",
}
err := p.SavePublishTarget(ctx, target)
require.NoError(t, err)
assert.NotZero(t, target.ID)
targets, err := p.SelectPublishTargetsOfSite(ctx, site.ID)
require.NoError(t, err)
require.Len(t, targets, 1)
assert.Equal(t, target.ID, targets[0].ID)
assert.Equal(t, site.ID, targets[0].SiteID)
assert.Equal(t, models.PublishTargetTypeNetlify, targets[0].TargetType)
assert.Equal(t, "https://example.netlify.app", targets[0].BaseURL)
assert.Equal(t, "netlify-site-123", targets[0].TargetRef)
assert.Equal(t, "secret-key", targets[0].TargetKey)
})
t.Run("select targets for site with no targets", func(t *testing.T) {
emptySite := &models.Site{
OwnerID: user.ID,
Title: "No Targets",
Tagline: "",
}
require.NoError(t, p.SaveSite(ctx, emptySite))
targets, err := p.SelectPublishTargetsOfSite(ctx, emptySite.ID)
require.NoError(t, err)
assert.Empty(t, targets)
})
}
// Verify that password encoding roundtrips correctly through base64
func TestProvider_UserPasswordEncoding(t *testing.T) {
ctx := context.Background()
p := newTestDB(t)
// Use bytes that aren't valid UTF-8 to verify binary safety
rawPassword := []byte{0x00, 0xff, 0x80, 0x7f, 0x01}
user := &models.User{
Username: "binuser",
PasswordHashed: rawPassword,
}
require.NoError(t, p.SaveUser(ctx, user))
got, err := p.SelectUserByUsername(ctx, "binuser")
require.NoError(t, err)
assert.Equal(t, rawPassword, got.PasswordHashed)
// Verify it's stored as base64 (not raw bytes) - this is implicit
// from the implementation but good to confirm the roundtrip works
_ = base64.StdEncoding.EncodeToString(rawPassword)
}

View file

@ -0,0 +1,48 @@
package db
import (
"context"
"lmika.dev/lmika/weiro/models"
"lmika.dev/lmika/weiro/providers/db/gen/sqlgen"
)
func (db *Provider) SelectPublishTargetsOfSite(ctx context.Context, siteID int64) ([]models.SitePublishTarget, error) {
rows, err := db.queries.SelectPublishTargetsOfSite(ctx, siteID)
if err != nil {
return nil, err
}
targets := make([]models.SitePublishTarget, len(rows))
for i, row := range rows {
targets[i] = models.SitePublishTarget{
ID: row.ID,
SiteID: row.SiteID,
TargetType: models.PublishTargetType(row.TargetType),
BaseURL: row.BaseUrl,
TargetRef: row.TargetRef,
TargetKey: row.TargetKey,
}
}
return targets, nil
}
func (db *Provider) SavePublishTarget(ctx context.Context, target *models.SitePublishTarget) error {
if target.ID == 0 {
newID, err := db.queries.InsertPublishTarget(ctx, sqlgen.InsertPublishTargetParams{
SiteID: target.SiteID,
TargetType: int64(target.TargetType),
BaseUrl: target.BaseURL,
TargetRef: target.TargetRef,
TargetKey: target.TargetKey,
})
if err != nil {
return err
}
target.ID = newID
return nil
}
// No update query defined in sqlgen yet
return nil
}

58
providers/db/sites.go Normal file
View file

@ -0,0 +1,58 @@
package db
import (
"context"
"lmika.dev/lmika/weiro/models"
"lmika.dev/lmika/weiro/providers/db/gen/sqlgen"
)
func (db *Provider) SelectSiteByID(ctx context.Context, id int64) (models.Site, error) {
row, err := db.queries.SelectSiteByID(ctx, id)
if err != nil {
return models.Site{}, err
}
return models.Site{
ID: row.ID,
OwnerID: row.OwnerID,
Title: row.Title,
Tagline: row.Tagline,
}, nil
}
func (db *Provider) SelectSitesOwnedByUser(ctx context.Context, ownerID int64) ([]models.Site, error) {
rows, err := db.queries.SelectSitesOwnedByUser(ctx, ownerID)
if err != nil {
return nil, err
}
sites := make([]models.Site, len(rows))
for i, row := range rows {
sites[i] = models.Site{
ID: row.ID,
OwnerID: row.OwnerID,
Title: row.Title,
Tagline: row.Tagline,
}
}
return sites, nil
}
func (db *Provider) SaveSite(ctx context.Context, site *models.Site) error {
if site.ID == 0 {
newID, err := db.queries.InsertSite(ctx, sqlgen.InsertSiteParams{
OwnerID: site.OwnerID,
Title: site.Title,
Tagline: site.Tagline,
})
if err != nil {
return err
}
site.ID = newID
return nil
}
// No update query defined in sqlgen yet
return nil
}

49
providers/db/users.go Normal file
View file

@ -0,0 +1,49 @@
package db
import (
"context"
"encoding/base64"
"lmika.dev/lmika/weiro/models"
"lmika.dev/lmika/weiro/providers/db/gen/sqlgen"
)
func (db *Provider) SelectUserByUsername(ctx context.Context, username string) (models.User, error) {
res, err := db.queries.SelectUserByUsername(ctx, username)
if err != nil {
return models.User{}, err
}
pwdBytes, err := base64.StdEncoding.DecodeString(res.Password)
if err != nil {
return models.User{}, err
}
return models.User{
ID: res.ID,
Username: res.Username,
PasswordHashed: pwdBytes,
}, nil
}
func (db *Provider) SaveUser(ctx context.Context, user *models.User) error {
hashedPassword := base64.StdEncoding.EncodeToString(user.PasswordHashed)
if user.ID == 0 {
newID, err := db.queries.InsertUser(ctx, sqlgen.InsertUserParams{
Username: user.Username,
Password: hashedPassword,
})
if err != nil {
return err
}
user.ID = newID
return nil
}
return db.queries.UpdateUser(ctx, sqlgen.UpdateUserParams{
ID: user.ID,
Username: user.Username,
Password: hashedPassword,
})
}

View file

@ -109,7 +109,7 @@ func (b *Builder) renderPost(post *models.Post) (postSingleData, error) {
return postSingleData{
commonData: commonData{Site: b.site},
Path: postPath,
Meta: post,
Post: post,
HTML: template.HTML(md.String()),
}, nil
}

View file

@ -16,7 +16,7 @@ func TestBuilder_BuildSite(t *testing.T) {
t.Run("build site", func(t *testing.T) {
tmpls := fstest.MapFS{
"posts_single.html": {Data: []byte(`{{ .HTML }}`)},
"posts_list.html": {Data: []byte(`{{ range .Posts}}<a href="{{url_abs .Path}}">{{.Meta.Title}}</a>,{{ end }}`)},
"posts_list.html": {Data: []byte(`{{ range .Posts}}<a href="{{url_abs .Path}}">{{.Post.Title}}</a>,{{ end }}`)},
"layout_main.html": {Data: []byte(`{{ .Body }}`)},
}
@ -59,4 +59,4 @@ func TestBuilder_BuildSite(t *testing.T) {
}
})
}
}

View file

@ -38,7 +38,7 @@ type commonData struct {
type postSingleData struct {
commonData
Meta *models.Post
Post *models.Post
HTML template.HTML
Path string
}

View file

@ -7,9 +7,8 @@ import (
)
type ReadSiteModels struct {
Site models.Site
Target models.SitePublishTarget
Posts []*models.Post
Site models.Site
Posts []*models.Post
}
type siteMeta struct {

View file

@ -39,14 +39,10 @@ func (p *Provider) ReadSite() (ReadSiteModels, error) {
Title: meta.Title,
Tagline: meta.Tagline,
}
publishTarget := models.SitePublishTarget{
BaseURL: meta.BaseURL,
}
return ReadSiteModels{
Site: site,
Target: publishTarget,
Posts: posts,
Site: site,
Posts: posts,
}, nil
}