ai: generate postgres package draft

This commit is contained in:
nkanaev
2026-06-13 14:46:34 +01:00
parent eef482d81d
commit 5c2d9bfc4c
7 changed files with 954 additions and 0 deletions

View File

@@ -0,0 +1,133 @@
package postgres
import (
"database/sql"
"log"
"github.com/nkanaev/yarr/src/storage/model"
)
func (s *PostgresStorage) CreateFeed(params model.CreateFeedParams) *model.Feed {
title := params.Title
if title == "" {
title = params.FeedLink
}
row := s.db.QueryRow(`
insert into feeds (title, description, link, feed_link, folder_id)
values ($1, $2, $3, $4, $5)
on conflict (feed_link) do update set folder_id = $5
returning id`,
title,
params.Description,
params.Link,
params.FeedLink,
params.FolderID,
)
var id int64
err := row.Scan(&id)
if err != nil {
log.Print(err)
return nil
}
return &model.Feed{
Id: id,
Title: title,
Description: params.Description,
Link: params.Link,
FeedLink: params.FeedLink,
FolderId: params.FolderID,
}
}
func (s *PostgresStorage) DeleteFeed(feedId int64) bool {
result, err := s.db.Exec(`delete from feeds where id = $1`, feedId)
if err != nil {
log.Print(err)
return false
}
nrows, err := result.RowsAffected()
if err != nil {
log.Print(err)
return false
}
return nrows == 1
}
func (s *PostgresStorage) UpdateFeed(feedId int64, params model.UpdateFeedParams) (bool, error) {
_, err := s.db.Exec(`
update feeds set
title = coalesce($2, title),
feed_link = coalesce($3, feed_link),
folder_id = case when $4 then $5 else folder_id end,
icon = case when $6 then $7 else icon end
where id = $1
`,
feedId,
params.Title,
params.FeedLink,
params.FolderID.Set,
params.FolderID.Value,
params.Icon.Set,
params.Icon.Value,
)
if err != nil {
log.Print(err)
return false, err
}
return true, nil
}
func (s *PostgresStorage) ListFeeds() []model.Feed {
result := make([]model.Feed, 0)
rows, err := s.db.Query(`
select id, folder_id, title, description, link, feed_link,
coalesce(length(icon), 0) > 0 as has_icon
from feeds
order by lower(title)
`)
if err != nil {
log.Print(err)
return result
}
defer rows.Close()
for rows.Next() {
var f model.Feed
err = rows.Scan(
&f.Id,
&f.FolderId,
&f.Title,
&f.Description,
&f.Link,
&f.FeedLink,
&f.HasIcon,
)
if err != nil {
log.Print(err)
return result
}
result = append(result, f)
}
return result
}
func (s *PostgresStorage) GetFeed(id int64) *model.Feed {
var f model.Feed
err := s.db.QueryRow(`
select
id, folder_id, title, link, feed_link,
icon, coalesce(length(icon), 0) > 0 as has_icon
from feeds where id = $1
`, id).Scan(
&f.Id, &f.FolderId, &f.Title, &f.Link, &f.FeedLink,
&f.Icon, &f.HasIcon,
)
if err != nil {
if err != sql.ErrNoRows {
log.Print(err)
}
return nil
}
return &f
}

View File

@@ -0,0 +1,105 @@
package postgres
import (
"database/sql"
"github.com/nkanaev/yarr/src/storage/model"
)
func (s *PostgresStorage) ListFeedStates() ([]model.FeedState, error) {
rows, err := s.db.Query(`
select
feed_id
, last_refreshed
, last_error
, http_lmod
, http_etag
from feed_states
`)
if err != nil {
return nil, err
}
defer rows.Close()
states := make([]model.FeedState, 0)
for rows.Next() {
var state model.FeedState
err := rows.Scan(
&state.FeedID,
&state.LastRefreshed,
&state.LastError,
&state.HTTPLastModified,
&state.HTTPEtag,
)
if err != nil {
return nil, err
}
states = append(states, state)
}
return states, nil
}
func (s *PostgresStorage) GetFeedState(feedID int64) (*model.FeedState, error) {
var state model.FeedState
err := s.db.QueryRow(`
select
feed_id
, last_refreshed
, last_error
, http_lmod
, http_etag
from feed_states where feed_id = $1
`, feedID).Scan(
&state.FeedID,
&state.LastRefreshed,
&state.LastError,
&state.HTTPLastModified,
&state.HTTPEtag,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &state, nil
}
func (s *PostgresStorage) UpdateFeedState(feedID int64, params model.UpdateFeedStateParams) (bool, error) {
lastError := params.LastError
if lastError != nil && *lastError == "" {
lastError = nil
}
_, err := s.db.Exec(`
insert into feed_states (
feed_id
, last_refreshed
, last_error
, http_lmod
, http_etag
)
values (
$1
, coalesce($2, '1970-01-01 00:00:00+00'::timestamptz)
, coalesce($3, '')
, coalesce($4, '')
, coalesce($5, '')
)
on conflict (feed_id) do update set
last_refreshed = coalesce($2, last_refreshed),
last_error = coalesce($3, last_error),
http_lmod = coalesce($4, http_lmod),
http_etag = coalesce($5, http_etag)
`,
feedID,
params.LastRefreshed,
params.LastError,
params.HTTPLastModified,
params.HTTPEtag,
)
if err != nil {
return false, err
}
return true, nil
}

View File

@@ -0,0 +1,77 @@
package postgres
import (
"log"
"github.com/nkanaev/yarr/src/storage/model"
)
func (s *PostgresStorage) CreateFolder(title string) *model.Folder {
expanded := true
row := s.db.QueryRow(`
insert into folders (title, is_expanded) values ($1, $2)
on conflict (title) do update set title = $1
returning id`,
title,
expanded,
)
var id int64
err := row.Scan(&id)
if err != nil {
log.Print(err)
return nil
}
return &model.Folder{Id: id, Title: title, IsExpanded: expanded}
}
func (s *PostgresStorage) DeleteFolder(folderId int64) bool {
_, err := s.db.Exec(`delete from folders where id = $1`, folderId)
if err != nil {
log.Print(err)
}
return err == nil
}
func (s *PostgresStorage) UpdateFolder(folderId int64, params model.UpdateFolderParams) (bool, error) {
_, err := s.db.Exec(`
update folders set
title = coalesce($2, title),
is_expanded = coalesce($3, is_expanded)
where id = $1
`,
folderId,
params.Title,
params.IsExpanded,
)
if err != nil {
log.Print(err)
return false, err
}
return true, nil
}
func (s *PostgresStorage) ListFolders() []model.Folder {
result := make([]model.Folder, 0)
rows, err := s.db.Query(`
select id, title, is_expanded
from folders
order by lower(title)
`)
if err != nil {
log.Print(err)
return result
}
defer rows.Close()
for rows.Next() {
var f model.Folder
err = rows.Scan(&f.Id, &f.Title, &f.IsExpanded)
if err != nil {
log.Print(err)
return result
}
result = append(result, f)
}
return result
}

View File

@@ -0,0 +1,332 @@
package postgres
import (
"cmp"
"database/sql/driver"
"encoding/json"
"fmt"
"log"
"slices"
"strings"
"time"
"github.com/nkanaev/yarr/src/storage/model"
)
type MediaLinks model.MediaLinks
func (m *MediaLinks) Scan(src any) error {
switch data := src.(type) {
case []byte:
return json.Unmarshal(data, m)
case string:
return json.Unmarshal([]byte(data), m)
default:
return nil
}
}
func (m MediaLinks) Value() (driver.Value, error) {
return json.Marshal(m)
}
func (s *PostgresStorage) CreateItems(items []model.Item) bool {
tx, err := s.db.Begin()
if err != nil {
log.Print(err)
return false
}
now := time.Now().UTC()
slices.SortStableFunc(items, func(a, b model.Item) int {
sa := a.Date.Format(time.RFC3339) + "::" + a.GUID
sb := b.Date.Format(time.RFC3339) + "::" + b.GUID
return cmp.Compare(sa, sb)
})
for _, item := range items {
_, err = tx.Exec(`
insert into items (
guid, feed_id, title, link, date,
content, media_links,
date_arrived, last_arrived, status
)
values (
$1, $2, $3, $4, $5,
$6, $7,
$8, $9, $10
)
on conflict (feed_id, guid) do update set
last_arrived = excluded.last_arrived`,
item.GUID,
item.FeedId,
item.Title,
item.Link,
item.Date,
item.Content,
MediaLinks(item.MediaLinks),
now,
now,
model.UNREAD,
)
if err != nil {
log.Print(err)
if err = tx.Rollback(); err != nil {
log.Print(err)
return false
}
return false
}
}
if err = tx.Commit(); err != nil {
log.Print(err)
return false
}
return true
}
func listQueryPredicate(filter model.ItemFilter, newestFirst bool) (string, []any) {
cond := make([]string, 0)
args := make([]any, 0)
n := 0
next := func() int {
n++
return n
}
if filter.FolderID != nil {
cond = append(cond, fmt.Sprintf("i.feed_id in (select id from feeds where folder_id = $%d)", next()))
args = append(args, *filter.FolderID)
}
if filter.FeedID != nil {
cond = append(cond, fmt.Sprintf("i.feed_id = $%d", next()))
args = append(args, *filter.FeedID)
}
if filter.Status != nil {
cond = append(cond, fmt.Sprintf("i.status = $%d", next()))
args = append(args, *filter.Status)
}
if filter.Search != nil {
words := strings.Fields(*filter.Search)
terms := make([]string, len(words))
for idx, word := range words {
terms[idx] = word + ":*"
}
cond = append(cond, fmt.Sprintf(
"i.search @@ to_tsquery('english', $%d)", next(),
))
args = append(args, strings.Join(terms, " & "))
}
if filter.After != nil {
compare := ">"
if newestFirst {
compare = "<"
}
cond = append(cond, fmt.Sprintf(
"(i.date, i.id) %s (select date, id from items where id = $%d)",
compare, next(),
))
args = append(args, *filter.After)
}
if filter.IDs != nil && len(*filter.IDs) > 0 {
placeholders := make([]string, len(*filter.IDs))
for i, id := range *filter.IDs {
placeholders[i] = fmt.Sprintf("$%d", next())
args = append(args, id)
}
cond = append(cond, "i.id in ("+strings.Join(placeholders, ",")+")")
}
if filter.SinceID != nil {
cond = append(cond, fmt.Sprintf("i.id > $%d", next()))
args = append(args, filter.SinceID)
}
if filter.MaxID != nil {
cond = append(cond, fmt.Sprintf("i.id < $%d", next()))
args = append(args, filter.MaxID)
}
if filter.Before != nil {
cond = append(cond, fmt.Sprintf("i.date < $%d", next()))
args = append(args, filter.Before)
}
predicate := "1"
if len(cond) > 0 {
predicate = strings.Join(cond, " and ")
}
return predicate, args
}
func (s *PostgresStorage) CountItems() int {
var count int
err := s.db.QueryRow(`select count(*) from items`).Scan(&count)
if err != nil {
log.Print(err)
return 0
}
return count
}
func (s *PostgresStorage) ListItems(
filter model.ItemFilter,
limit int,
newestFirst bool,
withContent bool,
) []model.Item {
predicate, args := listQueryPredicate(filter, newestFirst)
result := make([]model.Item, 0)
order := "date desc, id desc"
if !newestFirst {
order = "date asc, id asc"
}
if filter.IDs != nil || filter.SinceID != nil {
order = "i.id asc"
}
if filter.MaxID != nil {
order = "i.id desc"
}
selectCols := "i.id, i.guid, i.feed_id, i.title, i.link, i.date, i.status, i.media_links"
if withContent {
selectCols += ", i.content"
} else {
selectCols += ", '' as content"
}
query := fmt.Sprintf(`
select %s
from items i
where %s
order by %s
limit %d
`, selectCols, predicate, order, limit)
rows, err := s.db.Query(query, args...)
if err != nil {
log.Print(err)
return result
}
defer rows.Close()
for rows.Next() {
var x model.Item
err = rows.Scan(
&x.Id, &x.GUID, &x.FeedId,
&x.Title, &x.Link, &x.Date,
&x.Status, (*MediaLinks)(&x.MediaLinks), &x.Content,
)
if err != nil {
log.Print(err)
return result
}
result = append(result, x)
}
return result
}
func (s *PostgresStorage) GetItem(id int64) *model.Item {
i := &model.Item{}
err := s.db.QueryRow(`
select
i.id, i.guid, i.feed_id, i.title, i.link, i.content,
i.date, i.status, i.media_links
from items i
where i.id = $1
`, id).Scan(
&i.Id, &i.GUID, &i.FeedId, &i.Title, &i.Link, &i.Content,
&i.Date, &i.Status, (*MediaLinks)(&i.MediaLinks),
)
if err != nil {
log.Print(err)
return nil
}
return i
}
func (s *PostgresStorage) UpdateItemStatus(item_id int64, status model.ItemStatus) bool {
_, err := s.db.Exec(`update items set status = $2 where id = $1`,
item_id,
status,
)
return err == nil
}
func (s *PostgresStorage) MarkItemsRead(filter model.MarkFilter) bool {
predicate, args := listQueryPredicate(model.ItemFilter{
FolderID: filter.FolderID,
FeedID: filter.FeedID,
Before: filter.Before,
}, false)
query := fmt.Sprintf(`
update items as i set status = %d
where %s and i.status != %d
`, model.READ, predicate, model.STARRED)
_, err := s.db.Exec(query, args...)
if err != nil {
log.Print(err)
}
return err == nil
}
func (s *PostgresStorage) FeedStats() []model.FeedStat {
result := make([]model.FeedStat, 0)
rows, err := s.db.Query(fmt.Sprintf(`
select
feed_id,
sum(case status when %d then 1 else 0 end),
sum(case status when %d then 1 else 0 end)
from items
group by feed_id
`, model.UNREAD, model.STARRED))
if err != nil {
log.Print(err)
return result
}
defer rows.Close()
for rows.Next() {
stat := model.FeedStat{}
rows.Scan(&stat.FeedId, &stat.UnreadCount, &stat.StarredCount)
result = append(result, stat)
}
return result
}
var (
itemsKeepSize = 50
itemsKeepDays = 90
)
func (s *PostgresStorage) DeleteOldItems() {
keepDaysLimit := fmt.Sprintf("-%d days", itemsKeepDays)
result, err := s.db.Exec(`
delete from items
where id in (
select id
from (
select
id,
row_number() over (partition by feed_id order by date desc) as rn,
last_arrived,
max(last_arrived) over (partition by feed_id) as max_la
from items
where status != $1
) sub
where rn > $2
and last_arrived < max_la + $3::interval
)`,
model.STARRED,
itemsKeepSize,
keepDaysLimit,
)
if err != nil {
log.Print(err)
return
}
numDeleted, err := result.RowsAffected()
if err == nil && numDeleted > 0 {
log.Printf("Deleted %d old items", numDeleted)
}
}

View File

@@ -0,0 +1,142 @@
package postgres
import (
"database/sql"
"log"
)
var migrations = []func(*sql.Tx) error{
m01_initial,
}
var maxVersion = int64(len(migrations))
func migrate(db *sql.DB) error {
var version int64
err := db.QueryRow(
`select coalesce(max(version), 0) from schema_version`,
).Scan(&version)
if err != nil {
return err
}
if version >= maxVersion {
return nil
}
log.Printf("db version is %d. migrating to %d", version, maxVersion)
for v := version + 1; v <= maxVersion; v++ {
log.Printf("[migration:%d] starting", v)
tx, err := db.Begin()
if err != nil {
return err
}
if err := migrations[v-1](tx); err != nil {
tx.Rollback()
return err
}
if _, err := tx.Exec(
`insert into schema_version (version) values ($1)
on conflict do nothing`, v,
); err != nil {
tx.Rollback()
return err
}
if err := tx.Commit(); err != nil {
return err
}
log.Printf("[migration:%d] done", v)
}
return nil
}
func m01_initial(tx *sql.Tx) error {
stmts := []string{
`create table if not exists schema_version (
version bigint primary key
)`,
`create table if not exists folders (
id bigserial primary key,
title text not null,
is_expanded boolean not null default false
)`,
`create unique index if not exists idx_folder_title on folders(title)`,
`create table if not exists feeds (
id bigserial primary key,
folder_id bigint references folders(id) on delete set null,
title text not null,
description text,
link text,
feed_link text not null,
icon bytea
)`,
`create index if not exists idx_feed_folder_id on feeds(folder_id)`,
`create unique index if not exists idx_feed_feed_link on feeds(feed_link)`,
`create table if not exists items (
id bigserial primary key,
guid text not null,
feed_id bigint not null references feeds(id) on delete cascade,
title text,
link text,
content text,
date timestamptz,
date_arrived timestamptz,
last_arrived timestamptz,
status integer,
media_links jsonb
)`,
`create index if not exists idx_item_feed_id on items(feed_id)`,
`create index if not exists idx_item__date_id_status on items(date, id, status)`,
`create unique index if not exists idx_item_guid on items(feed_id, guid)`,
`alter table items add column if not exists search tsvector`,
`create index if not exists idx_item_search on items using gin(search)`,
`create or replace function items_search_update() returns trigger as $$
begin
new.search := to_tsvector('english',
coalesce(new.title, '') || ' ' ||
coalesce(regexp_replace(new.content, '<[^>]+>', '', 'g'), '')
);
return new;
end;
$$ language plpgsql`,
`create trigger if not exists trg_items_search_insert
before insert on items
for each row execute function items_search_update()`,
`create trigger if not exists trg_items_search_update
before update of title, content on items
for each row execute function items_search_update()`,
`create table if not exists settings (
key text primary key,
val jsonb
)`,
`create table if not exists feed_states (
feed_id bigint primary key references feeds(id) on delete cascade,
last_refreshed timestamptz not null default '1970-01-01 00:00:00+00',
last_error text not null default '',
http_lmod text not null default '',
http_etag text not null default ''
)`,
}
for _, stmt := range stmts {
if _, err := tx.Exec(stmt); err != nil {
return err
}
}
return nil
}

View File

@@ -0,0 +1,131 @@
package postgres
import (
"encoding/json"
"log"
"github.com/nkanaev/yarr/src/storage/model"
)
func settingsDefaults() model.Settings {
return model.Settings{
Filter: "",
Feed: "",
FeedListWidth: 300,
ItemListWidth: 300,
SortNewestFirst: true,
ThemeName: "light",
ThemeFont: "",
ThemeSize: 1,
RefreshRate: 0,
Language: "en",
}
}
func (s *PostgresStorage) GetSettings() model.Settings {
result := settingsDefaults()
rows, err := s.db.Query(`select key, val from settings;`)
if err != nil {
log.Print(err)
return result
}
defer rows.Close()
for rows.Next() {
var key string
var val []byte
rows.Scan(&key, &val)
switch key {
case "filter":
json.Unmarshal(val, &result.Filter)
case "feed":
json.Unmarshal(val, &result.Feed)
case "feed_list_width":
json.Unmarshal(val, &result.FeedListWidth)
case "item_list_width":
json.Unmarshal(val, &result.ItemListWidth)
case "sort_newest_first":
json.Unmarshal(val, &result.SortNewestFirst)
case "theme_name":
json.Unmarshal(val, &result.ThemeName)
case "theme_font":
json.Unmarshal(val, &result.ThemeFont)
case "theme_size":
json.Unmarshal(val, &result.ThemeSize)
case "refresh_rate":
json.Unmarshal(val, &result.RefreshRate)
case "language":
json.Unmarshal(val, &result.Language)
}
}
return result
}
func (s *PostgresStorage) UpdateSettings(params model.UpdateSettingsParams) bool {
tx, err := s.db.Begin()
if err != nil {
log.Print(err)
return false
}
defer tx.Rollback()
update := func(key string, val any) error {
valEncoded, err := json.Marshal(val)
if err != nil {
return err
}
_, err = tx.Exec(`
insert into settings (key, val) values ($1, $2)
on conflict (key) do update set val = $2`,
key,
valEncoded,
)
return err
}
var errs []error
if params.Filter != nil {
errs = append(errs, update("filter", *params.Filter))
}
if params.Feed != nil {
errs = append(errs, update("feed", *params.Feed))
}
if params.FeedListWidth != nil {
errs = append(errs, update("feed_list_width", *params.FeedListWidth))
}
if params.ItemListWidth != nil {
errs = append(errs, update("item_list_width", *params.ItemListWidth))
}
if params.SortNewestFirst != nil {
errs = append(errs, update("sort_newest_first", *params.SortNewestFirst))
}
if params.ThemeName != nil {
errs = append(errs, update("theme_name", *params.ThemeName))
}
if params.ThemeFont != nil {
errs = append(errs, update("theme_font", *params.ThemeFont))
}
if params.ThemeSize != nil {
errs = append(errs, update("theme_size", *params.ThemeSize))
}
if params.RefreshRate != nil {
errs = append(errs, update("refresh_rate", *params.RefreshRate))
}
if params.Language != nil {
errs = append(errs, update("language", *params.Language))
}
for _, err := range errs {
if err != nil {
log.Print(err)
return false
}
}
if err := tx.Commit(); err != nil {
log.Print(err)
return false
}
return true
}

View File

@@ -0,0 +1,34 @@
package postgres
import (
"database/sql"
"log"
_ "github.com/lib/pq"
)
type PostgresStorage struct {
db *sql.DB
}
func New(connStr string) (*PostgresStorage, error) {
db, err := sql.Open("postgres", connStr)
if err != nil {
return nil, err
}
if err := db.Ping(); err != nil {
return nil, err
}
if err := migrate(db); err != nil {
return nil, err
}
log.Print("connected to postgres")
return &PostgresStorage{db: db}, nil
}
func (s *PostgresStorage) Close() error {
return s.db.Close()
}