forked from
tangled.org/core
Monorepo for Tangled — https://tangled.org
1package db
2
3import (
4 "database/sql"
5 "errors"
6 "fmt"
7 "log"
8 "strings"
9 "time"
10
11 "github.com/bluesky-social/indigo/atproto/syntax"
12 "tangled.org/core/appview/models"
13)
14
15type Star struct {
16 StarredByDid string
17 RepoAt syntax.ATURI
18 Created time.Time
19 Rkey string
20
21 // optionally, populate this when querying for reverse mappings
22 Repo *models.Repo
23}
24
25func (star *Star) ResolveRepo(e Execer) error {
26 if star.Repo != nil {
27 return nil
28 }
29
30 repo, err := GetRepoByAtUri(e, star.RepoAt.String())
31 if err != nil {
32 return err
33 }
34
35 star.Repo = repo
36 return nil
37}
38
39func AddStar(e Execer, star *Star) error {
40 query := `insert or ignore into stars (starred_by_did, repo_at, rkey) values (?, ?, ?)`
41 _, err := e.Exec(
42 query,
43 star.StarredByDid,
44 star.RepoAt.String(),
45 star.Rkey,
46 )
47 return err
48}
49
50// Get a star record
51func GetStar(e Execer, starredByDid string, repoAt syntax.ATURI) (*Star, error) {
52 query := `
53 select starred_by_did, repo_at, created, rkey
54 from stars
55 where starred_by_did = ? and repo_at = ?`
56 row := e.QueryRow(query, starredByDid, repoAt)
57
58 var star Star
59 var created string
60 err := row.Scan(&star.StarredByDid, &star.RepoAt, &created, &star.Rkey)
61 if err != nil {
62 return nil, err
63 }
64
65 createdAtTime, err := time.Parse(time.RFC3339, created)
66 if err != nil {
67 log.Println("unable to determine followed at time")
68 star.Created = time.Now()
69 } else {
70 star.Created = createdAtTime
71 }
72
73 return &star, nil
74}
75
76// Remove a star
77func DeleteStar(e Execer, starredByDid string, repoAt syntax.ATURI) error {
78 _, err := e.Exec(`delete from stars where starred_by_did = ? and repo_at = ?`, starredByDid, repoAt)
79 return err
80}
81
82// Remove a star
83func DeleteStarByRkey(e Execer, starredByDid string, rkey string) error {
84 _, err := e.Exec(`delete from stars where starred_by_did = ? and rkey = ?`, starredByDid, rkey)
85 return err
86}
87
88func GetStarCount(e Execer, repoAt syntax.ATURI) (int, error) {
89 stars := 0
90 err := e.QueryRow(
91 `select count(starred_by_did) from stars where repo_at = ?`, repoAt).Scan(&stars)
92 if err != nil {
93 return 0, err
94 }
95 return stars, nil
96}
97
98// getStarStatuses returns a map of repo URIs to star status for a given user
99// This is an internal helper function to avoid N+1 queries
100func getStarStatuses(e Execer, userDid string, repoAts []syntax.ATURI) (map[string]bool, error) {
101 if len(repoAts) == 0 || userDid == "" {
102 return make(map[string]bool), nil
103 }
104
105 placeholders := make([]string, len(repoAts))
106 args := make([]any, len(repoAts)+1)
107 args[0] = userDid
108
109 for i, repoAt := range repoAts {
110 placeholders[i] = "?"
111 args[i+1] = repoAt.String()
112 }
113
114 query := fmt.Sprintf(`
115 SELECT repo_at
116 FROM stars
117 WHERE starred_by_did = ? AND repo_at IN (%s)
118 `, strings.Join(placeholders, ","))
119
120 rows, err := e.Query(query, args...)
121 if err != nil {
122 return nil, err
123 }
124 defer rows.Close()
125
126 result := make(map[string]bool)
127 // Initialize all repos as not starred
128 for _, repoAt := range repoAts {
129 result[repoAt.String()] = false
130 }
131
132 // Mark starred repos as true
133 for rows.Next() {
134 var repoAt string
135 if err := rows.Scan(&repoAt); err != nil {
136 return nil, err
137 }
138 result[repoAt] = true
139 }
140
141 return result, nil
142}
143
144func GetStarStatus(e Execer, userDid string, repoAt syntax.ATURI) bool {
145 statuses, err := getStarStatuses(e, userDid, []syntax.ATURI{repoAt})
146 if err != nil {
147 return false
148 }
149 return statuses[repoAt.String()]
150}
151
152// GetStarStatuses returns a map of repo URIs to star status for a given user
153func GetStarStatuses(e Execer, userDid string, repoAts []syntax.ATURI) (map[string]bool, error) {
154 return getStarStatuses(e, userDid, repoAts)
155}
156func GetStars(e Execer, limit int, filters ...filter) ([]Star, error) {
157 var conditions []string
158 var args []any
159 for _, filter := range filters {
160 conditions = append(conditions, filter.Condition())
161 args = append(args, filter.Arg()...)
162 }
163
164 whereClause := ""
165 if conditions != nil {
166 whereClause = " where " + strings.Join(conditions, " and ")
167 }
168
169 limitClause := ""
170 if limit != 0 {
171 limitClause = fmt.Sprintf(" limit %d", limit)
172 }
173
174 repoQuery := fmt.Sprintf(
175 `select starred_by_did, repo_at, created, rkey
176 from stars
177 %s
178 order by created desc
179 %s`,
180 whereClause,
181 limitClause,
182 )
183 rows, err := e.Query(repoQuery, args...)
184 if err != nil {
185 return nil, err
186 }
187
188 starMap := make(map[string][]Star)
189 for rows.Next() {
190 var star Star
191 var created string
192 err := rows.Scan(&star.StarredByDid, &star.RepoAt, &created, &star.Rkey)
193 if err != nil {
194 return nil, err
195 }
196
197 star.Created = time.Now()
198 if t, err := time.Parse(time.RFC3339, created); err == nil {
199 star.Created = t
200 }
201
202 repoAt := string(star.RepoAt)
203 starMap[repoAt] = append(starMap[repoAt], star)
204 }
205
206 // populate *Repo in each star
207 args = make([]any, len(starMap))
208 i := 0
209 for r := range starMap {
210 args[i] = r
211 i++
212 }
213
214 if len(args) == 0 {
215 return nil, nil
216 }
217
218 repos, err := GetRepos(e, 0, FilterIn("at_uri", args))
219 if err != nil {
220 return nil, err
221 }
222
223 for _, r := range repos {
224 if stars, ok := starMap[string(r.RepoAt())]; ok {
225 for i := range stars {
226 stars[i].Repo = &r
227 }
228 }
229 }
230
231 var stars []Star
232 for _, s := range starMap {
233 stars = append(stars, s...)
234 }
235
236 return stars, nil
237}
238
239func CountStars(e Execer, filters ...filter) (int64, error) {
240 var conditions []string
241 var args []any
242 for _, filter := range filters {
243 conditions = append(conditions, filter.Condition())
244 args = append(args, filter.Arg()...)
245 }
246
247 whereClause := ""
248 if conditions != nil {
249 whereClause = " where " + strings.Join(conditions, " and ")
250 }
251
252 repoQuery := fmt.Sprintf(`select count(1) from stars %s`, whereClause)
253 var count int64
254 err := e.QueryRow(repoQuery, args...).Scan(&count)
255
256 if !errors.Is(err, sql.ErrNoRows) && err != nil {
257 return 0, err
258 }
259
260 return count, nil
261}
262
263func GetAllStars(e Execer, limit int) ([]Star, error) {
264 var stars []Star
265
266 rows, err := e.Query(`
267 select
268 s.starred_by_did,
269 s.repo_at,
270 s.rkey,
271 s.created,
272 r.did,
273 r.name,
274 r.knot,
275 r.rkey,
276 r.created
277 from stars s
278 join repos r on s.repo_at = r.at_uri
279 `)
280
281 if err != nil {
282 return nil, err
283 }
284 defer rows.Close()
285
286 for rows.Next() {
287 var star Star
288 var repo models.Repo
289 var starCreatedAt, repoCreatedAt string
290
291 if err := rows.Scan(
292 &star.StarredByDid,
293 &star.RepoAt,
294 &star.Rkey,
295 &starCreatedAt,
296 &repo.Did,
297 &repo.Name,
298 &repo.Knot,
299 &repo.Rkey,
300 &repoCreatedAt,
301 ); err != nil {
302 return nil, err
303 }
304
305 star.Created, err = time.Parse(time.RFC3339, starCreatedAt)
306 if err != nil {
307 star.Created = time.Now()
308 }
309 repo.Created, err = time.Parse(time.RFC3339, repoCreatedAt)
310 if err != nil {
311 repo.Created = time.Now()
312 }
313 star.Repo = &repo
314
315 stars = append(stars, star)
316 }
317
318 if err := rows.Err(); err != nil {
319 return nil, err
320 }
321
322 return stars, nil
323}
324
325// GetTopStarredReposLastWeek returns the top 8 most starred repositories from the last week
326func GetTopStarredReposLastWeek(e Execer) ([]models.Repo, error) {
327 // first, get the top repo URIs by star count from the last week
328 query := `
329 with recent_starred_repos as (
330 select distinct repo_at
331 from stars
332 where created >= datetime('now', '-7 days')
333 ),
334 repo_star_counts as (
335 select
336 s.repo_at,
337 count(*) as stars_gained_last_week
338 from stars s
339 join recent_starred_repos rsr on s.repo_at = rsr.repo_at
340 where s.created >= datetime('now', '-7 days')
341 group by s.repo_at
342 )
343 select rsc.repo_at
344 from repo_star_counts rsc
345 order by rsc.stars_gained_last_week desc
346 limit 8
347 `
348
349 rows, err := e.Query(query)
350 if err != nil {
351 return nil, err
352 }
353 defer rows.Close()
354
355 var repoUris []string
356 for rows.Next() {
357 var repoUri string
358 err := rows.Scan(&repoUri)
359 if err != nil {
360 return nil, err
361 }
362 repoUris = append(repoUris, repoUri)
363 }
364
365 if err := rows.Err(); err != nil {
366 return nil, err
367 }
368
369 if len(repoUris) == 0 {
370 return []models.Repo{}, nil
371 }
372
373 // get full repo data
374 repos, err := GetRepos(e, 0, FilterIn("at_uri", repoUris))
375 if err != nil {
376 return nil, err
377 }
378
379 // sort repos by the original trending order
380 repoMap := make(map[string]models.Repo)
381 for _, repo := range repos {
382 repoMap[repo.RepoAt().String()] = repo
383 }
384
385 orderedRepos := make([]models.Repo, 0, len(repoUris))
386 for _, uri := range repoUris {
387 if repo, exists := repoMap[uri]; exists {
388 orderedRepos = append(orderedRepos, repo)
389 }
390 }
391
392 return orderedRepos, nil
393}