internal/store/snippets.go

bd49b87fce895e9f0a7588152548fb6e1821ac7d
gitbay/internal/store/snippets.go history · blame · raw

225 lines · 6346 bytes

  1package store
  2
  3import (
  4	"database/sql"
  5	"errors"
  6)
  7
  8type Snippet struct {
  9	ID          int64
 10	PublicID    string
 11	OwnerID     int64
 12	OwnerName   string
 13	Description string
 14	Visibility  string // public | unlisted | private
 15	CreatedAt   string
 16	UpdatedAt   string
 17	// Files carries names and sizes. Content is filled by SnippetFiles and
 18	// SnippetFile only, so a listing does not read every body.
 19	Files []SnippetFile
 20}
 21
 22type SnippetFile struct {
 23	Name    string
 24	Size    int64
 25	Content []byte
 26}
 27
 28const snippetSelect = `
 29	SELECT s.id, s.public_id, s.owner_id, u.username, s.description, s.visibility, s.created_at, s.updated_at
 30	FROM snippets s JOIN users u ON u.id = s.owner_id`
 31
 32func scanSnippet(row interface{ Scan(...any) error }) (Snippet, error) {
 33	var sn Snippet
 34	err := row.Scan(&sn.ID, &sn.PublicID, &sn.OwnerID, &sn.OwnerName, &sn.Description, &sn.Visibility, &sn.CreatedAt, &sn.UpdatedAt)
 35	return sn, err
 36}
 37
 38// CreateSnippet inserts the snippet and its first file in one transaction.
 39// A public_id collision is ErrExists so the caller can draw another.
 40func (s *Store) CreateSnippet(ownerID int64, publicID, description, visibility, name string, content []byte) (int64, error) {
 41	tx, err := s.DB.Begin()
 42	if err != nil {
 43		return 0, err
 44	}
 45	defer tx.Rollback()
 46	res, err := tx.Exec(
 47		"INSERT INTO snippets (public_id, owner_id, description, visibility) VALUES (?, ?, ?, ?)",
 48		publicID, ownerID, description, visibility)
 49	if err != nil {
 50		if isUniqueErr(err) {
 51			return 0, ErrExists
 52		}
 53		return 0, err
 54	}
 55	id, err := res.LastInsertId()
 56	if err != nil {
 57		return 0, err
 58	}
 59	if _, err := tx.Exec("INSERT INTO snippet_files (snippet_id, name, content, size) VALUES (?, ?, ?, ?)",
 60		id, name, content, len(content)); err != nil {
 61		return 0, err
 62	}
 63	return id, tx.Commit()
 64}
 65
 66func (s *Store) SnippetByPublicID(publicID string) (Snippet, error) {
 67	sn, err := scanSnippet(s.DB.QueryRow(snippetSelect+" WHERE s.public_id = ?", publicID))
 68	if errors.Is(err, sql.ErrNoRows) {
 69		return sn, ErrNotFound
 70	}
 71	if err != nil {
 72		return sn, err
 73	}
 74	sn.Files, err = s.snippetFileNames(sn.ID)
 75	return sn, err
 76}
 77
 78func (s *Store) snippetFileNames(id int64) ([]SnippetFile, error) {
 79	rows, err := s.DB.Query("SELECT name, size FROM snippet_files WHERE snippet_id = ? ORDER BY name", id)
 80	if err != nil {
 81		return nil, err
 82	}
 83	defer rows.Close()
 84	var out []SnippetFile
 85	for rows.Next() {
 86		var f SnippetFile
 87		if err := rows.Scan(&f.Name, &f.Size); err != nil {
 88			return nil, err
 89		}
 90		out = append(out, f)
 91	}
 92	return out, rows.Err()
 93}
 94
 95// SnippetFiles returns every file with its content, by name.
 96func (s *Store) SnippetFiles(id int64) ([]SnippetFile, error) {
 97	rows, err := s.DB.Query("SELECT name, size, content FROM snippet_files WHERE snippet_id = ? ORDER BY name", id)
 98	if err != nil {
 99		return nil, err
100	}
101	defer rows.Close()
102	var out []SnippetFile
103	for rows.Next() {
104		var f SnippetFile
105		if err := rows.Scan(&f.Name, &f.Size, &f.Content); err != nil {
106			return nil, err
107		}
108		out = append(out, f)
109	}
110	return out, rows.Err()
111}
112
113func (s *Store) SnippetFile(id int64, name string) (SnippetFile, error) {
114	var f SnippetFile
115	err := s.DB.QueryRow("SELECT name, size, content FROM snippet_files WHERE snippet_id = ? AND name = ?", id, name).
116		Scan(&f.Name, &f.Size, &f.Content)
117	if errors.Is(err, sql.ErrNoRows) {
118		return f, ErrNotFound
119	}
120	return f, err
121}
122
123// ListSnippets lists an owner's snippets newest first. all=false keeps
124// public ones only. afterID is the keyset cursor: rows older than it.
125// Ids grow with creation, so ordering by id is creation order.
126func (s *Store) ListSnippets(ownerID int64, all bool, limit int, afterID int64) ([]Snippet, error) {
127	q := snippetSelect + " WHERE s.owner_id = ?"
128	args := []any{ownerID}
129	if !all {
130		q += " AND s.visibility = 'public'"
131	}
132	if afterID > 0 {
133		q += " AND s.id < ?"
134		args = append(args, afterID)
135	}
136	q += " ORDER BY s.id DESC"
137	if limit > 0 {
138		q += " LIMIT ?"
139		args = append(args, limit)
140	}
141	rows, err := s.DB.Query(q, args...)
142	if err != nil {
143		return nil, err
144	}
145	defer rows.Close()
146	var out []Snippet
147	for rows.Next() {
148		sn, err := scanSnippet(rows)
149		if err != nil {
150			return nil, err
151		}
152		out = append(out, sn)
153	}
154	if err := rows.Err(); err != nil {
155		return nil, err
156	}
157	// One query per row for the names. Command callers page at 200 rows
158	// or fewer; the web list page is uncapped, which the per-account
159	// snippet limit bounds.
160	for i := range out {
161		if out[i].Files, err = s.snippetFileNames(out[i].ID); err != nil {
162			return nil, err
163		}
164	}
165	return out, nil
166}
167
168func (s *Store) CountSnippets(ownerID int64, all bool) (int, error) {
169	q := "SELECT COUNT(*) FROM snippets WHERE owner_id = ?"
170	if !all {
171		q += " AND visibility = 'public'"
172	}
173	var n int
174	err := s.DB.QueryRow(q, ownerID).Scan(&n)
175	return n, err
176}
177
178func (s *Store) UpdateSnippet(id int64, description, visibility string) error {
179	_, err := s.DB.Exec(
180		"UPDATE snippets SET description = ?, visibility = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
181		description, visibility, id)
182	return err
183}
184
185func (s *Store) DeleteSnippet(id int64) error {
186	_, err := s.DB.Exec("DELETE FROM snippets WHERE id = ?", id)
187	return err
188}
189
190// SetSnippetFile adds the file or replaces one of the same name.
191func (s *Store) SetSnippetFile(id int64, name string, content []byte) error {
192	tx, err := s.DB.Begin()
193	if err != nil {
194		return err
195	}
196	defer tx.Rollback()
197	if _, err := tx.Exec(`INSERT INTO snippet_files (snippet_id, name, content, size) VALUES (?, ?, ?, ?)
198		ON CONFLICT (snippet_id, name) DO UPDATE SET content = excluded.content, size = excluded.size`,
199		id, name, content, len(content)); err != nil {
200		return err
201	}
202	if _, err := tx.Exec("UPDATE snippets SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?", id); err != nil {
203		return err
204	}
205	return tx.Commit()
206}
207
208func (s *Store) RemoveSnippetFile(id int64, name string) error {
209	tx, err := s.DB.Begin()
210	if err != nil {
211		return err
212	}
213	defer tx.Rollback()
214	res, err := tx.Exec("DELETE FROM snippet_files WHERE snippet_id = ? AND name = ?", id, name)
215	if err != nil {
216		return err
217	}
218	if n, _ := res.RowsAffected(); n == 0 {
219		return ErrNotFound
220	}
221	if _, err := tx.Exec("UPDATE snippets SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?", id); err != nil {
222		return err
223	}
224	return tx.Commit()
225}