diff options
Diffstat (limited to 'backend')
| -rw-r--r-- | backend/db/articles.sql.go | 192 | ||||
| -rw-r--r-- | backend/db/feeds.sql.go | 36 | ||||
| -rw-r--r-- | backend/db/migrations/006_add_composite_index.sql | 1 | ||||
| -rw-r--r-- | backend/db/queries/articles.sql | 32 | ||||
| -rw-r--r-- | backend/db/queries/feeds.sql | 7 | ||||
| -rw-r--r-- | backend/db/schema.sql | 2 | ||||
| -rw-r--r-- | backend/graphql/generated.go | 754 | ||||
| -rw-r--r-- | backend/graphql/model/generated.go | 18 | ||||
| -rw-r--r-- | backend/graphql/resolver/pagination.go | 177 | ||||
| -rw-r--r-- | backend/graphql/resolver/schema.resolvers.go | 75 |
10 files changed, 1164 insertions, 130 deletions
diff --git a/backend/db/articles.sql.go b/backend/db/articles.sql.go index 7f6400b..329cb18 100644 --- a/backend/db/articles.sql.go +++ b/backend/db/articles.sql.go @@ -192,18 +192,96 @@ func (q *Queries) GetArticlesByFeed(ctx context.Context, feedID int64) ([]Articl return items, nil } -const getReadArticles = `-- name: GetReadArticles :many +const getArticlesByFeedPaginated = `-- name: GetArticlesByFeedPaginated :many SELECT a.id, a.feed_id, a.guid, a.title, a.url, a.is_read, f.id as feed_id_2, f.url as feed_url, f.title as feed_title, f.is_subscribed as feed_is_subscribed FROM articles AS a INNER JOIN feeds AS f ON a.feed_id = f.id -WHERE a.is_read = 1 AND f.is_subscribed = 1 AND f.user_id = ? +WHERE a.is_read = ? AND f.is_subscribed = 1 AND f.user_id = ? AND a.feed_id = ? ORDER BY a.id DESC -LIMIT 100 +LIMIT ? ` -type GetReadArticlesRow struct { +type GetArticlesByFeedPaginatedParams struct { + IsRead int64 + UserID int64 + FeedID int64 + Limit int64 +} + +type GetArticlesByFeedPaginatedRow struct { + ID int64 + FeedID int64 + Guid string + Title string + Url string + IsRead int64 + FeedID2 int64 + FeedUrl string + FeedTitle string + FeedIsSubscribed int64 +} + +func (q *Queries) GetArticlesByFeedPaginated(ctx context.Context, arg GetArticlesByFeedPaginatedParams) ([]GetArticlesByFeedPaginatedRow, error) { + rows, err := q.db.QueryContext(ctx, getArticlesByFeedPaginated, + arg.IsRead, + arg.UserID, + arg.FeedID, + arg.Limit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetArticlesByFeedPaginatedRow{} + for rows.Next() { + var i GetArticlesByFeedPaginatedRow + if err := rows.Scan( + &i.ID, + &i.FeedID, + &i.Guid, + &i.Title, + &i.Url, + &i.IsRead, + &i.FeedID2, + &i.FeedUrl, + &i.FeedTitle, + &i.FeedIsSubscribed, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getArticlesByFeedPaginatedAfter = `-- name: GetArticlesByFeedPaginatedAfter :many +SELECT + a.id, a.feed_id, a.guid, a.title, a.url, a.is_read, + f.id as feed_id_2, f.url as feed_url, f.title as feed_title, f.is_subscribed as feed_is_subscribed +FROM articles AS a +INNER JOIN feeds AS f ON a.feed_id = f.id +WHERE a.is_read = ? AND f.is_subscribed = 1 AND f.user_id = ? AND a.feed_id = ? AND a.id < ? +ORDER BY a.id DESC +LIMIT ? +` + +type GetArticlesByFeedPaginatedAfterParams struct { + IsRead int64 + UserID int64 + FeedID int64 + ID int64 + Limit int64 +} + +type GetArticlesByFeedPaginatedAfterRow struct { ID int64 FeedID int64 Guid string @@ -216,15 +294,85 @@ type GetReadArticlesRow struct { FeedIsSubscribed int64 } -func (q *Queries) GetReadArticles(ctx context.Context, userID int64) ([]GetReadArticlesRow, error) { - rows, err := q.db.QueryContext(ctx, getReadArticles, userID) +func (q *Queries) GetArticlesByFeedPaginatedAfter(ctx context.Context, arg GetArticlesByFeedPaginatedAfterParams) ([]GetArticlesByFeedPaginatedAfterRow, error) { + rows, err := q.db.QueryContext(ctx, getArticlesByFeedPaginatedAfter, + arg.IsRead, + arg.UserID, + arg.FeedID, + arg.ID, + arg.Limit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetArticlesByFeedPaginatedAfterRow{} + for rows.Next() { + var i GetArticlesByFeedPaginatedAfterRow + if err := rows.Scan( + &i.ID, + &i.FeedID, + &i.Guid, + &i.Title, + &i.Url, + &i.IsRead, + &i.FeedID2, + &i.FeedUrl, + &i.FeedTitle, + &i.FeedIsSubscribed, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getArticlesPaginated = `-- name: GetArticlesPaginated :many +SELECT + a.id, a.feed_id, a.guid, a.title, a.url, a.is_read, + f.id as feed_id_2, f.url as feed_url, f.title as feed_title, f.is_subscribed as feed_is_subscribed +FROM articles AS a +INNER JOIN feeds AS f ON a.feed_id = f.id +WHERE a.is_read = ? AND f.is_subscribed = 1 AND f.user_id = ? +ORDER BY a.id DESC +LIMIT ? +` + +type GetArticlesPaginatedParams struct { + IsRead int64 + UserID int64 + Limit int64 +} + +type GetArticlesPaginatedRow struct { + ID int64 + FeedID int64 + Guid string + Title string + Url string + IsRead int64 + FeedID2 int64 + FeedUrl string + FeedTitle string + FeedIsSubscribed int64 +} + +func (q *Queries) GetArticlesPaginated(ctx context.Context, arg GetArticlesPaginatedParams) ([]GetArticlesPaginatedRow, error) { + rows, err := q.db.QueryContext(ctx, getArticlesPaginated, arg.IsRead, arg.UserID, arg.Limit) if err != nil { return nil, err } defer rows.Close() - items := []GetReadArticlesRow{} + items := []GetArticlesPaginatedRow{} for rows.Next() { - var i GetReadArticlesRow + var i GetArticlesPaginatedRow if err := rows.Scan( &i.ID, &i.FeedID, @@ -250,18 +398,25 @@ func (q *Queries) GetReadArticles(ctx context.Context, userID int64) ([]GetReadA return items, nil } -const getUnreadArticles = `-- name: GetUnreadArticles :many +const getArticlesPaginatedAfter = `-- name: GetArticlesPaginatedAfter :many SELECT a.id, a.feed_id, a.guid, a.title, a.url, a.is_read, f.id as feed_id_2, f.url as feed_url, f.title as feed_title, f.is_subscribed as feed_is_subscribed FROM articles AS a INNER JOIN feeds AS f ON a.feed_id = f.id -WHERE a.is_read = 0 AND f.is_subscribed = 1 AND f.user_id = ? +WHERE a.is_read = ? AND f.is_subscribed = 1 AND f.user_id = ? AND a.id < ? ORDER BY a.id DESC -LIMIT 100 +LIMIT ? ` -type GetUnreadArticlesRow struct { +type GetArticlesPaginatedAfterParams struct { + IsRead int64 + UserID int64 + ID int64 + Limit int64 +} + +type GetArticlesPaginatedAfterRow struct { ID int64 FeedID int64 Guid string @@ -274,15 +429,20 @@ type GetUnreadArticlesRow struct { FeedIsSubscribed int64 } -func (q *Queries) GetUnreadArticles(ctx context.Context, userID int64) ([]GetUnreadArticlesRow, error) { - rows, err := q.db.QueryContext(ctx, getUnreadArticles, userID) +func (q *Queries) GetArticlesPaginatedAfter(ctx context.Context, arg GetArticlesPaginatedAfterParams) ([]GetArticlesPaginatedAfterRow, error) { + rows, err := q.db.QueryContext(ctx, getArticlesPaginatedAfter, + arg.IsRead, + arg.UserID, + arg.ID, + arg.Limit, + ) if err != nil { return nil, err } defer rows.Close() - items := []GetUnreadArticlesRow{} + items := []GetArticlesPaginatedAfterRow{} for rows.Next() { - var i GetUnreadArticlesRow + var i GetArticlesPaginatedAfterRow if err := rows.Scan( &i.ID, &i.FeedID, diff --git a/backend/db/feeds.sql.go b/backend/db/feeds.sql.go index cec228a..0226a7d 100644 --- a/backend/db/feeds.sql.go +++ b/backend/db/feeds.sql.go @@ -96,6 +96,42 @@ func (q *Queries) GetFeedByURL(ctx context.Context, arg GetFeedByURLParams) (Fee return i, err } +const getFeedUnreadCounts = `-- name: GetFeedUnreadCounts :many +SELECT f.id as feed_id, COUNT(a.id) as unread_count +FROM feeds AS f +LEFT JOIN articles AS a ON f.id = a.feed_id AND a.is_read = 0 +WHERE f.is_subscribed = 1 AND f.user_id = ? +GROUP BY f.id +` + +type GetFeedUnreadCountsRow struct { + FeedID int64 + UnreadCount int64 +} + +func (q *Queries) GetFeedUnreadCounts(ctx context.Context, userID int64) ([]GetFeedUnreadCountsRow, error) { + rows, err := q.db.QueryContext(ctx, getFeedUnreadCounts, userID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetFeedUnreadCountsRow{} + for rows.Next() { + var i GetFeedUnreadCountsRow + if err := rows.Scan(&i.FeedID, &i.UnreadCount); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getFeeds = `-- name: GetFeeds :many SELECT id, url, title, fetched_at, is_subscribed, user_id FROM feeds diff --git a/backend/db/migrations/006_add_composite_index.sql b/backend/db/migrations/006_add_composite_index.sql new file mode 100644 index 0000000..d3d378b --- /dev/null +++ b/backend/db/migrations/006_add_composite_index.sql @@ -0,0 +1 @@ +CREATE INDEX IF NOT EXISTS idx_articles_feed_read_id ON articles(feed_id, is_read, id DESC); diff --git a/backend/db/queries/articles.sql b/backend/db/queries/articles.sql index 2c00678..1554530 100644 --- a/backend/db/queries/articles.sql +++ b/backend/db/queries/articles.sql @@ -6,25 +6,45 @@ FROM articles AS a INNER JOIN feeds AS f ON a.feed_id = f.id WHERE a.id = ?; --- name: GetUnreadArticles :many +-- name: GetArticlesPaginated :many SELECT a.id, a.feed_id, a.guid, a.title, a.url, a.is_read, f.id as feed_id_2, f.url as feed_url, f.title as feed_title, f.is_subscribed as feed_is_subscribed FROM articles AS a INNER JOIN feeds AS f ON a.feed_id = f.id -WHERE a.is_read = 0 AND f.is_subscribed = 1 AND f.user_id = ? +WHERE a.is_read = ? AND f.is_subscribed = 1 AND f.user_id = ? ORDER BY a.id DESC -LIMIT 100; +LIMIT ?; --- name: GetReadArticles :many +-- name: GetArticlesPaginatedAfter :many SELECT a.id, a.feed_id, a.guid, a.title, a.url, a.is_read, f.id as feed_id_2, f.url as feed_url, f.title as feed_title, f.is_subscribed as feed_is_subscribed FROM articles AS a INNER JOIN feeds AS f ON a.feed_id = f.id -WHERE a.is_read = 1 AND f.is_subscribed = 1 AND f.user_id = ? +WHERE a.is_read = ? AND f.is_subscribed = 1 AND f.user_id = ? AND a.id < ? ORDER BY a.id DESC -LIMIT 100; +LIMIT ?; + +-- name: GetArticlesByFeedPaginated :many +SELECT + a.id, a.feed_id, a.guid, a.title, a.url, a.is_read, + f.id as feed_id_2, f.url as feed_url, f.title as feed_title, f.is_subscribed as feed_is_subscribed +FROM articles AS a +INNER JOIN feeds AS f ON a.feed_id = f.id +WHERE a.is_read = ? AND f.is_subscribed = 1 AND f.user_id = ? AND a.feed_id = ? +ORDER BY a.id DESC +LIMIT ?; + +-- name: GetArticlesByFeedPaginatedAfter :many +SELECT + a.id, a.feed_id, a.guid, a.title, a.url, a.is_read, + f.id as feed_id_2, f.url as feed_url, f.title as feed_title, f.is_subscribed as feed_is_subscribed +FROM articles AS a +INNER JOIN feeds AS f ON a.feed_id = f.id +WHERE a.is_read = ? AND f.is_subscribed = 1 AND f.user_id = ? AND a.feed_id = ? AND a.id < ? +ORDER BY a.id DESC +LIMIT ?; -- name: GetArticlesByFeed :many SELECT id, feed_id, guid, title, url, is_read diff --git a/backend/db/queries/feeds.sql b/backend/db/queries/feeds.sql index acf36d2..094a0f8 100644 --- a/backend/db/queries/feeds.sql +++ b/backend/db/queries/feeds.sql @@ -37,3 +37,10 @@ WHERE is_subscribed = 1; UPDATE feeds SET is_subscribed = 0 WHERE id = ?; + +-- name: GetFeedUnreadCounts :many +SELECT f.id as feed_id, COUNT(a.id) as unread_count +FROM feeds AS f +LEFT JOIN articles AS a ON f.id = a.feed_id AND a.is_read = 0 +WHERE f.is_subscribed = 1 AND f.user_id = ? +GROUP BY f.id; diff --git a/backend/db/schema.sql b/backend/db/schema.sql index 07ac72d..2596c84 100644 --- a/backend/db/schema.sql +++ b/backend/db/schema.sql @@ -37,3 +37,5 @@ CREATE INDEX IF NOT EXISTS idx_articles_is_read ON articles(is_read); CREATE INDEX IF NOT EXISTS idx_articles_guid ON articles(guid); CREATE INDEX IF NOT EXISTS idx_feeds_user_id ON feeds(user_id); + +CREATE INDEX IF NOT EXISTS idx_articles_feed_read_id ON articles(feed_id, is_read, id DESC); diff --git a/backend/graphql/generated.go b/backend/graphql/generated.go index 72379bc..ebc130a 100644 --- a/backend/graphql/generated.go +++ b/backend/graphql/generated.go @@ -56,6 +56,11 @@ type ComplexityRoot struct { URL func(childComplexity int) int } + ArticleConnection struct { + Articles func(childComplexity int) int + PageInfo func(childComplexity int) int + } + AuthPayload struct { User func(childComplexity int) int } @@ -67,6 +72,7 @@ type ComplexityRoot struct { IsSubscribed func(childComplexity int) int Title func(childComplexity int) int URL func(childComplexity int) int + UnreadCount func(childComplexity int) int } Mutation struct { @@ -80,13 +86,18 @@ type ComplexityRoot struct { UnsubscribeFeed func(childComplexity int, id string) int } + PageInfo struct { + EndCursor func(childComplexity int) int + HasNextPage func(childComplexity int) int + } + Query struct { Article func(childComplexity int, id string) int CurrentUser func(childComplexity int) int Feed func(childComplexity int, id string) int Feeds func(childComplexity int) int - ReadArticles func(childComplexity int) int - UnreadArticles func(childComplexity int) int + ReadArticles func(childComplexity int, feedID *string, after *string, first *int32) int + UnreadArticles func(childComplexity int, feedID *string, after *string, first *int32) int } User struct { @@ -107,8 +118,8 @@ type MutationResolver interface { } type QueryResolver interface { Feeds(ctx context.Context) ([]*model.Feed, error) - UnreadArticles(ctx context.Context) ([]*model.Article, error) - ReadArticles(ctx context.Context) ([]*model.Article, error) + UnreadArticles(ctx context.Context, feedID *string, after *string, first *int32) (*model.ArticleConnection, error) + ReadArticles(ctx context.Context, feedID *string, after *string, first *int32) (*model.ArticleConnection, error) Feed(ctx context.Context, id string) (*model.Feed, error) Article(ctx context.Context, id string) (*model.Article, error) CurrentUser(ctx context.Context) (*model.User, error) @@ -182,6 +193,20 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.Article.URL(childComplexity), true + case "ArticleConnection.articles": + if e.complexity.ArticleConnection.Articles == nil { + break + } + + return e.complexity.ArticleConnection.Articles(childComplexity), true + + case "ArticleConnection.pageInfo": + if e.complexity.ArticleConnection.PageInfo == nil { + break + } + + return e.complexity.ArticleConnection.PageInfo(childComplexity), true + case "AuthPayload.user": if e.complexity.AuthPayload.User == nil { break @@ -231,6 +256,13 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.Feed.URL(childComplexity), true + case "Feed.unreadCount": + if e.complexity.Feed.UnreadCount == nil { + break + } + + return e.complexity.Feed.UnreadCount(childComplexity), true + case "Mutation.addFeed": if e.complexity.Mutation.AddFeed == nil { break @@ -322,6 +354,20 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.Mutation.UnsubscribeFeed(childComplexity, args["id"].(string)), true + case "PageInfo.endCursor": + if e.complexity.PageInfo.EndCursor == nil { + break + } + + return e.complexity.PageInfo.EndCursor(childComplexity), true + + case "PageInfo.hasNextPage": + if e.complexity.PageInfo.HasNextPage == nil { + break + } + + return e.complexity.PageInfo.HasNextPage(childComplexity), true + case "Query.article": if e.complexity.Query.Article == nil { break @@ -365,14 +411,24 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin break } - return e.complexity.Query.ReadArticles(childComplexity), true + args, err := ec.field_Query_readArticles_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Query.ReadArticles(childComplexity, args["feedId"].(*string), args["after"].(*string), args["first"].(*int32)), true case "Query.unreadArticles": if e.complexity.Query.UnreadArticles == nil { break } - return e.complexity.Query.UnreadArticles(childComplexity), true + args, err := ec.field_Query_unreadArticles_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Query.UnreadArticles(childComplexity, args["feedId"].(*string), args["after"].(*string), args["first"].(*int32)), true case "User.id": if e.complexity.User.ID == nil { @@ -524,6 +580,11 @@ type Feed { isSubscribed: Boolean! """ + Number of unread articles in this feed + """ + unreadCount: Int! + + """ Articles belonging to this feed """ articles: [Article!]! @@ -585,6 +646,36 @@ type User { } """ +Pagination information for cursor-based pagination +""" +type PageInfo { + """ + Whether there are more items after the last item in this page + """ + hasNextPage: Boolean! + + """ + Cursor of the last item in this page + """ + endCursor: ID +} + +""" +A paginated list of articles +""" +type ArticleConnection { + """ + The list of articles + """ + articles: [Article!]! + + """ + Pagination information + """ + pageInfo: PageInfo! +} + +""" Authentication payload returned from login mutation """ type AuthPayload { @@ -604,14 +695,14 @@ type Query { feeds: [Feed!]! """ - Get all unread articles across all feeds + Get unread articles with optional feed filter and cursor-based pagination """ - unreadArticles: [Article!]! + unreadArticles(feedId: ID, after: ID, first: Int): ArticleConnection! """ - Get all read articles across all feeds + Get read articles with optional feed filter and cursor-based pagination """ - readArticles: [Article!]! + readArticles(feedId: ID, after: ID, first: Int): ArticleConnection! """ Get a specific feed by ID @@ -984,6 +1075,154 @@ func (ec *executionContext) field_Query_feed_argsID( return zeroVal, nil } +func (ec *executionContext) field_Query_readArticles_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := ec.field_Query_readArticles_argsFeedID(ctx, rawArgs) + if err != nil { + return nil, err + } + args["feedId"] = arg0 + arg1, err := ec.field_Query_readArticles_argsAfter(ctx, rawArgs) + if err != nil { + return nil, err + } + args["after"] = arg1 + arg2, err := ec.field_Query_readArticles_argsFirst(ctx, rawArgs) + if err != nil { + return nil, err + } + args["first"] = arg2 + return args, nil +} +func (ec *executionContext) field_Query_readArticles_argsFeedID( + ctx context.Context, + rawArgs map[string]any, +) (*string, error) { + if _, ok := rawArgs["feedId"]; !ok { + var zeroVal *string + return zeroVal, nil + } + + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("feedId")) + if tmp, ok := rawArgs["feedId"]; ok { + return ec.unmarshalOID2ᚖstring(ctx, tmp) + } + + var zeroVal *string + return zeroVal, nil +} + +func (ec *executionContext) field_Query_readArticles_argsAfter( + ctx context.Context, + rawArgs map[string]any, +) (*string, error) { + if _, ok := rawArgs["after"]; !ok { + var zeroVal *string + return zeroVal, nil + } + + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("after")) + if tmp, ok := rawArgs["after"]; ok { + return ec.unmarshalOID2ᚖstring(ctx, tmp) + } + + var zeroVal *string + return zeroVal, nil +} + +func (ec *executionContext) field_Query_readArticles_argsFirst( + ctx context.Context, + rawArgs map[string]any, +) (*int32, error) { + if _, ok := rawArgs["first"]; !ok { + var zeroVal *int32 + return zeroVal, nil + } + + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("first")) + if tmp, ok := rawArgs["first"]; ok { + return ec.unmarshalOInt2ᚖint32(ctx, tmp) + } + + var zeroVal *int32 + return zeroVal, nil +} + +func (ec *executionContext) field_Query_unreadArticles_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := ec.field_Query_unreadArticles_argsFeedID(ctx, rawArgs) + if err != nil { + return nil, err + } + args["feedId"] = arg0 + arg1, err := ec.field_Query_unreadArticles_argsAfter(ctx, rawArgs) + if err != nil { + return nil, err + } + args["after"] = arg1 + arg2, err := ec.field_Query_unreadArticles_argsFirst(ctx, rawArgs) + if err != nil { + return nil, err + } + args["first"] = arg2 + return args, nil +} +func (ec *executionContext) field_Query_unreadArticles_argsFeedID( + ctx context.Context, + rawArgs map[string]any, +) (*string, error) { + if _, ok := rawArgs["feedId"]; !ok { + var zeroVal *string + return zeroVal, nil + } + + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("feedId")) + if tmp, ok := rawArgs["feedId"]; ok { + return ec.unmarshalOID2ᚖstring(ctx, tmp) + } + + var zeroVal *string + return zeroVal, nil +} + +func (ec *executionContext) field_Query_unreadArticles_argsAfter( + ctx context.Context, + rawArgs map[string]any, +) (*string, error) { + if _, ok := rawArgs["after"]; !ok { + var zeroVal *string + return zeroVal, nil + } + + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("after")) + if tmp, ok := rawArgs["after"]; ok { + return ec.unmarshalOID2ᚖstring(ctx, tmp) + } + + var zeroVal *string + return zeroVal, nil +} + +func (ec *executionContext) field_Query_unreadArticles_argsFirst( + ctx context.Context, + rawArgs map[string]any, +) (*int32, error) { + if _, ok := rawArgs["first"]; !ok { + var zeroVal *int32 + return zeroVal, nil + } + + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("first")) + if tmp, ok := rawArgs["first"]; ok { + return ec.unmarshalOInt2ᚖint32(ctx, tmp) + } + + var zeroVal *int32 + return zeroVal, nil +} + func (ec *executionContext) field___Directive_args_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} @@ -1417,6 +1656,8 @@ func (ec *executionContext) fieldContext_Article_feed(_ context.Context, field g return ec.fieldContext_Feed_fetchedAt(ctx, field) case "isSubscribed": return ec.fieldContext_Feed_isSubscribed(ctx, field) + case "unreadCount": + return ec.fieldContext_Feed_unreadCount(ctx, field) case "articles": return ec.fieldContext_Feed_articles(ctx, field) } @@ -1426,6 +1667,116 @@ func (ec *executionContext) fieldContext_Article_feed(_ context.Context, field g return fc, nil } +func (ec *executionContext) _ArticleConnection_articles(ctx context.Context, field graphql.CollectedField, obj *model.ArticleConnection) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_ArticleConnection_articles(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.Articles, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.([]*model.Article) + fc.Result = res + return ec.marshalNArticle2ᚕᚖundefᚗninjaᚋxᚋfeedakaᚋgraphqlᚋmodelᚐArticleᚄ(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_ArticleConnection_articles(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "ArticleConnection", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "id": + return ec.fieldContext_Article_id(ctx, field) + case "feedId": + return ec.fieldContext_Article_feedId(ctx, field) + case "guid": + return ec.fieldContext_Article_guid(ctx, field) + case "title": + return ec.fieldContext_Article_title(ctx, field) + case "url": + return ec.fieldContext_Article_url(ctx, field) + case "isRead": + return ec.fieldContext_Article_isRead(ctx, field) + case "feed": + return ec.fieldContext_Article_feed(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type Article", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) _ArticleConnection_pageInfo(ctx context.Context, field graphql.CollectedField, obj *model.ArticleConnection) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_ArticleConnection_pageInfo(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.PageInfo, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(*model.PageInfo) + fc.Result = res + return ec.marshalNPageInfo2ᚖundefᚗninjaᚋxᚋfeedakaᚋgraphqlᚋmodelᚐPageInfo(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_ArticleConnection_pageInfo(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "ArticleConnection", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "hasNextPage": + return ec.fieldContext_PageInfo_hasNextPage(ctx, field) + case "endCursor": + return ec.fieldContext_PageInfo_endCursor(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type PageInfo", field.Name) + }, + } + return fc, nil +} + func (ec *executionContext) _AuthPayload_user(ctx context.Context, field graphql.CollectedField, obj *model.AuthPayload) (ret graphql.Marshaler) { fc, err := ec.fieldContext_AuthPayload_user(ctx, field) if err != nil { @@ -1696,6 +2047,50 @@ func (ec *executionContext) fieldContext_Feed_isSubscribed(_ context.Context, fi return fc, nil } +func (ec *executionContext) _Feed_unreadCount(ctx context.Context, field graphql.CollectedField, obj *model.Feed) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Feed_unreadCount(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.UnreadCount, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(int32) + fc.Result = res + return ec.marshalNInt2int32(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Feed_unreadCount(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Feed", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Int does not have child fields") + }, + } + return fc, nil +} + func (ec *executionContext) _Feed_articles(ctx context.Context, field graphql.CollectedField, obj *model.Feed) (ret graphql.Marshaler) { fc, err := ec.fieldContext_Feed_articles(ctx, field) if err != nil { @@ -1805,6 +2200,8 @@ func (ec *executionContext) fieldContext_Mutation_addFeed(ctx context.Context, f return ec.fieldContext_Feed_fetchedAt(ctx, field) case "isSubscribed": return ec.fieldContext_Feed_isSubscribed(ctx, field) + case "unreadCount": + return ec.fieldContext_Feed_unreadCount(ctx, field) case "articles": return ec.fieldContext_Feed_articles(ctx, field) } @@ -2071,6 +2468,8 @@ func (ec *executionContext) fieldContext_Mutation_markFeedRead(ctx context.Conte return ec.fieldContext_Feed_fetchedAt(ctx, field) case "isSubscribed": return ec.fieldContext_Feed_isSubscribed(ctx, field) + case "unreadCount": + return ec.fieldContext_Feed_unreadCount(ctx, field) case "articles": return ec.fieldContext_Feed_articles(ctx, field) } @@ -2140,6 +2539,8 @@ func (ec *executionContext) fieldContext_Mutation_markFeedUnread(ctx context.Con return ec.fieldContext_Feed_fetchedAt(ctx, field) case "isSubscribed": return ec.fieldContext_Feed_isSubscribed(ctx, field) + case "unreadCount": + return ec.fieldContext_Feed_unreadCount(ctx, field) case "articles": return ec.fieldContext_Feed_articles(ctx, field) } @@ -2263,6 +2664,91 @@ func (ec *executionContext) fieldContext_Mutation_logout(_ context.Context, fiel return fc, nil } +func (ec *executionContext) _PageInfo_hasNextPage(ctx context.Context, field graphql.CollectedField, obj *model.PageInfo) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_PageInfo_hasNextPage(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.HasNextPage, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(bool) + fc.Result = res + return ec.marshalNBoolean2bool(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_PageInfo_hasNextPage(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "PageInfo", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _PageInfo_endCursor(ctx context.Context, field graphql.CollectedField, obj *model.PageInfo) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_PageInfo_endCursor(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.EndCursor, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*string) + fc.Result = res + return ec.marshalOID2ᚖstring(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_PageInfo_endCursor(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "PageInfo", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type ID does not have child fields") + }, + } + return fc, nil +} + func (ec *executionContext) _Query_feeds(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { fc, err := ec.fieldContext_Query_feeds(ctx, field) if err != nil { @@ -2312,6 +2798,8 @@ func (ec *executionContext) fieldContext_Query_feeds(_ context.Context, field gr return ec.fieldContext_Feed_fetchedAt(ctx, field) case "isSubscribed": return ec.fieldContext_Feed_isSubscribed(ctx, field) + case "unreadCount": + return ec.fieldContext_Feed_unreadCount(ctx, field) case "articles": return ec.fieldContext_Feed_articles(ctx, field) } @@ -2335,7 +2823,7 @@ func (ec *executionContext) _Query_unreadArticles(ctx context.Context, field gra }() resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { ctx = rctx // use context from middleware stack in children - return ec.resolvers.Query().UnreadArticles(rctx) + return ec.resolvers.Query().UnreadArticles(rctx, fc.Args["feedId"].(*string), fc.Args["after"].(*string), fc.Args["first"].(*int32)) }) if err != nil { ec.Error(ctx, err) @@ -2347,12 +2835,12 @@ func (ec *executionContext) _Query_unreadArticles(ctx context.Context, field gra } return graphql.Null } - res := resTmp.([]*model.Article) + res := resTmp.(*model.ArticleConnection) fc.Result = res - return ec.marshalNArticle2ᚕᚖundefᚗninjaᚋxᚋfeedakaᚋgraphqlᚋmodelᚐArticleᚄ(ctx, field.Selections, res) + return ec.marshalNArticleConnection2ᚖundefᚗninjaᚋxᚋfeedakaᚋgraphqlᚋmodelᚐArticleConnection(ctx, field.Selections, res) } -func (ec *executionContext) fieldContext_Query_unreadArticles(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { +func (ec *executionContext) fieldContext_Query_unreadArticles(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { fc = &graphql.FieldContext{ Object: "Query", Field: field, @@ -2360,24 +2848,25 @@ func (ec *executionContext) fieldContext_Query_unreadArticles(_ context.Context, IsResolver: true, Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { switch field.Name { - case "id": - return ec.fieldContext_Article_id(ctx, field) - case "feedId": - return ec.fieldContext_Article_feedId(ctx, field) - case "guid": - return ec.fieldContext_Article_guid(ctx, field) - case "title": - return ec.fieldContext_Article_title(ctx, field) - case "url": - return ec.fieldContext_Article_url(ctx, field) - case "isRead": - return ec.fieldContext_Article_isRead(ctx, field) - case "feed": - return ec.fieldContext_Article_feed(ctx, field) + case "articles": + return ec.fieldContext_ArticleConnection_articles(ctx, field) + case "pageInfo": + return ec.fieldContext_ArticleConnection_pageInfo(ctx, field) } - return nil, fmt.Errorf("no field named %q was found under type Article", field.Name) + return nil, fmt.Errorf("no field named %q was found under type ArticleConnection", field.Name) }, } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Query_unreadArticles_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } return fc, nil } @@ -2395,7 +2884,7 @@ func (ec *executionContext) _Query_readArticles(ctx context.Context, field graph }() resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { ctx = rctx // use context from middleware stack in children - return ec.resolvers.Query().ReadArticles(rctx) + return ec.resolvers.Query().ReadArticles(rctx, fc.Args["feedId"].(*string), fc.Args["after"].(*string), fc.Args["first"].(*int32)) }) if err != nil { ec.Error(ctx, err) @@ -2407,12 +2896,12 @@ func (ec *executionContext) _Query_readArticles(ctx context.Context, field graph } return graphql.Null } - res := resTmp.([]*model.Article) + res := resTmp.(*model.ArticleConnection) fc.Result = res - return ec.marshalNArticle2ᚕᚖundefᚗninjaᚋxᚋfeedakaᚋgraphqlᚋmodelᚐArticleᚄ(ctx, field.Selections, res) + return ec.marshalNArticleConnection2ᚖundefᚗninjaᚋxᚋfeedakaᚋgraphqlᚋmodelᚐArticleConnection(ctx, field.Selections, res) } -func (ec *executionContext) fieldContext_Query_readArticles(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { +func (ec *executionContext) fieldContext_Query_readArticles(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { fc = &graphql.FieldContext{ Object: "Query", Field: field, @@ -2420,24 +2909,25 @@ func (ec *executionContext) fieldContext_Query_readArticles(_ context.Context, f IsResolver: true, Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { switch field.Name { - case "id": - return ec.fieldContext_Article_id(ctx, field) - case "feedId": - return ec.fieldContext_Article_feedId(ctx, field) - case "guid": - return ec.fieldContext_Article_guid(ctx, field) - case "title": - return ec.fieldContext_Article_title(ctx, field) - case "url": - return ec.fieldContext_Article_url(ctx, field) - case "isRead": - return ec.fieldContext_Article_isRead(ctx, field) - case "feed": - return ec.fieldContext_Article_feed(ctx, field) + case "articles": + return ec.fieldContext_ArticleConnection_articles(ctx, field) + case "pageInfo": + return ec.fieldContext_ArticleConnection_pageInfo(ctx, field) } - return nil, fmt.Errorf("no field named %q was found under type Article", field.Name) + return nil, fmt.Errorf("no field named %q was found under type ArticleConnection", field.Name) }, } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Query_readArticles_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } return fc, nil } @@ -2487,6 +2977,8 @@ func (ec *executionContext) fieldContext_Query_feed(ctx context.Context, field g return ec.fieldContext_Feed_fetchedAt(ctx, field) case "isSubscribed": return ec.fieldContext_Feed_isSubscribed(ctx, field) + case "unreadCount": + return ec.fieldContext_Feed_unreadCount(ctx, field) case "articles": return ec.fieldContext_Feed_articles(ctx, field) } @@ -4869,6 +5361,50 @@ func (ec *executionContext) _Article(ctx context.Context, sel ast.SelectionSet, return out } +var articleConnectionImplementors = []string{"ArticleConnection"} + +func (ec *executionContext) _ArticleConnection(ctx context.Context, sel ast.SelectionSet, obj *model.ArticleConnection) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, articleConnectionImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("ArticleConnection") + case "articles": + out.Values[i] = ec._ArticleConnection_articles(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "pageInfo": + out.Values[i] = ec._ArticleConnection_pageInfo(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.processDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + var authPayloadImplementors = []string{"AuthPayload"} func (ec *executionContext) _AuthPayload(ctx context.Context, sel ast.SelectionSet, obj *model.AuthPayload) graphql.Marshaler { @@ -4944,6 +5480,11 @@ func (ec *executionContext) _Feed(ctx context.Context, sel ast.SelectionSet, obj if out.Values[i] == graphql.Null { out.Invalids++ } + case "unreadCount": + out.Values[i] = ec._Feed_unreadCount(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } case "articles": out.Values[i] = ec._Feed_articles(ctx, field, obj) if out.Values[i] == graphql.Null { @@ -5070,6 +5611,47 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) return out } +var pageInfoImplementors = []string{"PageInfo"} + +func (ec *executionContext) _PageInfo(ctx context.Context, sel ast.SelectionSet, obj *model.PageInfo) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, pageInfoImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("PageInfo") + case "hasNextPage": + out.Values[i] = ec._PageInfo_hasNextPage(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "endCursor": + out.Values[i] = ec._PageInfo_endCursor(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.processDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + var queryImplementors = []string{"Query"} func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) graphql.Marshaler { @@ -5680,6 +6262,20 @@ func (ec *executionContext) marshalNArticle2ᚖundefᚗninjaᚋxᚋfeedakaᚋgra return ec._Article(ctx, sel, v) } +func (ec *executionContext) marshalNArticleConnection2undefᚗninjaᚋxᚋfeedakaᚋgraphqlᚋmodelᚐArticleConnection(ctx context.Context, sel ast.SelectionSet, v model.ArticleConnection) graphql.Marshaler { + return ec._ArticleConnection(ctx, sel, &v) +} + +func (ec *executionContext) marshalNArticleConnection2ᚖundefᚗninjaᚋxᚋfeedakaᚋgraphqlᚋmodelᚐArticleConnection(ctx context.Context, sel ast.SelectionSet, v *model.ArticleConnection) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + ec.Errorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._ArticleConnection(ctx, sel, v) +} + func (ec *executionContext) marshalNAuthPayload2undefᚗninjaᚋxᚋfeedakaᚋgraphqlᚋmodelᚐAuthPayload(ctx context.Context, sel ast.SelectionSet, v model.AuthPayload) graphql.Marshaler { return ec._AuthPayload(ctx, sel, &v) } @@ -5800,6 +6396,32 @@ func (ec *executionContext) marshalNID2string(ctx context.Context, sel ast.Selec return res } +func (ec *executionContext) unmarshalNInt2int32(ctx context.Context, v any) (int32, error) { + res, err := graphql.UnmarshalInt32(v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNInt2int32(ctx context.Context, sel ast.SelectionSet, v int32) graphql.Marshaler { + _ = sel + res := graphql.MarshalInt32(v) + if res == graphql.Null { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + ec.Errorf(ctx, "the requested element is null which the schema does not allow") + } + } + return res +} + +func (ec *executionContext) marshalNPageInfo2ᚖundefᚗninjaᚋxᚋfeedakaᚋgraphqlᚋmodelᚐPageInfo(ctx context.Context, sel ast.SelectionSet, v *model.PageInfo) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + ec.Errorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._PageInfo(ctx, sel, v) +} + func (ec *executionContext) unmarshalNString2string(ctx context.Context, v any) (string, error) { res, err := graphql.UnmarshalString(v) return res, graphql.ErrorOnPath(ctx, err) @@ -6123,6 +6745,42 @@ func (ec *executionContext) marshalOFeed2ᚖundefᚗninjaᚋxᚋfeedakaᚋgraphq return ec._Feed(ctx, sel, v) } +func (ec *executionContext) unmarshalOID2ᚖstring(ctx context.Context, v any) (*string, error) { + if v == nil { + return nil, nil + } + res, err := graphql.UnmarshalID(v) + return &res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalOID2ᚖstring(ctx context.Context, sel ast.SelectionSet, v *string) graphql.Marshaler { + if v == nil { + return graphql.Null + } + _ = sel + _ = ctx + res := graphql.MarshalID(*v) + return res +} + +func (ec *executionContext) unmarshalOInt2ᚖint32(ctx context.Context, v any) (*int32, error) { + if v == nil { + return nil, nil + } + res, err := graphql.UnmarshalInt32(v) + return &res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalOInt2ᚖint32(ctx context.Context, sel ast.SelectionSet, v *int32) graphql.Marshaler { + if v == nil { + return graphql.Null + } + _ = sel + _ = ctx + res := graphql.MarshalInt32(*v) + return res +} + func (ec *executionContext) unmarshalOString2ᚖstring(ctx context.Context, v any) (*string, error) { if v == nil { return nil, nil diff --git a/backend/graphql/model/generated.go b/backend/graphql/model/generated.go index 11f9692..a305535 100644 --- a/backend/graphql/model/generated.go +++ b/backend/graphql/model/generated.go @@ -20,6 +20,14 @@ type Article struct { Feed *Feed `json:"feed"` } +// A paginated list of articles +type ArticleConnection struct { + // The list of articles + Articles []*Article `json:"articles"` + // Pagination information + PageInfo *PageInfo `json:"pageInfo"` +} + // Authentication payload returned from login mutation type AuthPayload struct { // The authenticated user @@ -38,6 +46,8 @@ type Feed struct { FetchedAt string `json:"fetchedAt"` // Whether the user is currently subscribed to this feed IsSubscribed bool `json:"isSubscribed"` + // Number of unread articles in this feed + UnreadCount int32 `json:"unreadCount"` // Articles belonging to this feed Articles []*Article `json:"articles"` } @@ -46,6 +56,14 @@ type Feed struct { type Mutation struct { } +// Pagination information for cursor-based pagination +type PageInfo struct { + // Whether there are more items after the last item in this page + HasNextPage bool `json:"hasNextPage"` + // Cursor of the last item in this page + EndCursor *string `json:"endCursor,omitempty"` +} + // Root query type for reading data type Query struct { } diff --git a/backend/graphql/resolver/pagination.go b/backend/graphql/resolver/pagination.go new file mode 100644 index 0000000..1a14650 --- /dev/null +++ b/backend/graphql/resolver/pagination.go @@ -0,0 +1,177 @@ +package resolver + +import ( + "context" + "fmt" + "strconv" + + "undef.ninja/x/feedaka/db" + "undef.ninja/x/feedaka/graphql/model" +) + +const defaultPageSize = 30 +const maxPageSize = 100 + +// articleRow is a common interface for all paginated article query rows. +type articleRow struct { + ID int64 + FeedID int64 + Guid string + Title string + Url string + IsRead int64 + FeedID2 int64 + FeedUrl string + FeedTitle string + FeedIsSubscribed int64 +} + +func toArticleRow(r any) articleRow { + switch v := r.(type) { + case db.GetArticlesPaginatedRow: + return articleRow{v.ID, v.FeedID, v.Guid, v.Title, v.Url, v.IsRead, v.FeedID2, v.FeedUrl, v.FeedTitle, v.FeedIsSubscribed} + case db.GetArticlesPaginatedAfterRow: + return articleRow{v.ID, v.FeedID, v.Guid, v.Title, v.Url, v.IsRead, v.FeedID2, v.FeedUrl, v.FeedTitle, v.FeedIsSubscribed} + case db.GetArticlesByFeedPaginatedRow: + return articleRow{v.ID, v.FeedID, v.Guid, v.Title, v.Url, v.IsRead, v.FeedID2, v.FeedUrl, v.FeedTitle, v.FeedIsSubscribed} + case db.GetArticlesByFeedPaginatedAfterRow: + return articleRow{v.ID, v.FeedID, v.Guid, v.Title, v.Url, v.IsRead, v.FeedID2, v.FeedUrl, v.FeedTitle, v.FeedIsSubscribed} + default: + panic("unexpected row type") + } +} + +func rowToArticle(row articleRow) *model.Article { + return &model.Article{ + ID: strconv.FormatInt(row.ID, 10), + FeedID: strconv.FormatInt(row.FeedID, 10), + GUID: row.Guid, + Title: row.Title, + URL: row.Url, + IsRead: row.IsRead == 1, + Feed: &model.Feed{ + ID: strconv.FormatInt(row.FeedID2, 10), + URL: row.FeedUrl, + Title: row.FeedTitle, + IsSubscribed: row.FeedIsSubscribed == 1, + }, + } +} + +func (r *queryResolver) paginatedArticles(ctx context.Context, isRead int64, feedID *string, after *string, first *int32) (*model.ArticleConnection, error) { + userID, err := getUserIDFromContext(ctx) + if err != nil { + return nil, err + } + + limit := int64(defaultPageSize) + if first != nil { + limit = int64(*first) + if limit <= 0 { + limit = int64(defaultPageSize) + } + if limit > maxPageSize { + limit = maxPageSize + } + } + + // Fetch limit+1 to determine hasNextPage + fetchLimit := limit + 1 + + var rawRows []any + + if feedID != nil { + parsedFeedID, err := strconv.ParseInt(*feedID, 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid feed ID: %w", err) + } + + if after != nil { + cursor, err := strconv.ParseInt(*after, 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid cursor: %w", err) + } + rows, err := r.Queries.GetArticlesByFeedPaginatedAfter(ctx, db.GetArticlesByFeedPaginatedAfterParams{ + IsRead: isRead, + UserID: userID, + FeedID: parsedFeedID, + ID: cursor, + Limit: fetchLimit, + }) + if err != nil { + return nil, fmt.Errorf("failed to query articles: %w", err) + } + for _, row := range rows { + rawRows = append(rawRows, row) + } + } else { + rows, err := r.Queries.GetArticlesByFeedPaginated(ctx, db.GetArticlesByFeedPaginatedParams{ + IsRead: isRead, + UserID: userID, + FeedID: parsedFeedID, + Limit: fetchLimit, + }) + if err != nil { + return nil, fmt.Errorf("failed to query articles: %w", err) + } + for _, row := range rows { + rawRows = append(rawRows, row) + } + } + } else { + if after != nil { + cursor, err := strconv.ParseInt(*after, 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid cursor: %w", err) + } + rows, err := r.Queries.GetArticlesPaginatedAfter(ctx, db.GetArticlesPaginatedAfterParams{ + IsRead: isRead, + UserID: userID, + ID: cursor, + Limit: fetchLimit, + }) + if err != nil { + return nil, fmt.Errorf("failed to query articles: %w", err) + } + for _, row := range rows { + rawRows = append(rawRows, row) + } + } else { + rows, err := r.Queries.GetArticlesPaginated(ctx, db.GetArticlesPaginatedParams{ + IsRead: isRead, + UserID: userID, + Limit: fetchLimit, + }) + if err != nil { + return nil, fmt.Errorf("failed to query articles: %w", err) + } + for _, row := range rows { + rawRows = append(rawRows, row) + } + } + } + + hasNextPage := int64(len(rawRows)) > limit + if hasNextPage { + rawRows = rawRows[:limit] + } + + articles := make([]*model.Article, 0, len(rawRows)) + for _, raw := range rawRows { + articles = append(articles, rowToArticle(toArticleRow(raw))) + } + + var endCursor *string + if len(articles) > 0 { + lastID := articles[len(articles)-1].ID + endCursor = &lastID + } + + return &model.ArticleConnection{ + Articles: articles, + PageInfo: &model.PageInfo{ + HasNextPage: hasNextPage, + EndCursor: endCursor, + }, + }, nil +} diff --git a/backend/graphql/resolver/schema.resolvers.go b/backend/graphql/resolver/schema.resolvers.go index 10f892f..0392945 100644 --- a/backend/graphql/resolver/schema.resolvers.go +++ b/backend/graphql/resolver/schema.resolvers.go @@ -311,6 +311,16 @@ func (r *queryResolver) Feeds(ctx context.Context) ([]*model.Feed, error) { return nil, fmt.Errorf("failed to query feeds: %w", err) } + // Fetch unread counts for all feeds + unreadCounts, err := r.Queries.GetFeedUnreadCounts(ctx, userID) + if err != nil { + return nil, fmt.Errorf("failed to query unread counts: %w", err) + } + countMap := make(map[int64]int64, len(unreadCounts)) + for _, uc := range unreadCounts { + countMap[uc.FeedID] = uc.UnreadCount + } + var feeds []*model.Feed for _, dbFeed := range dbFeeds { feeds = append(feeds, &model.Feed{ @@ -319,6 +329,7 @@ func (r *queryResolver) Feeds(ctx context.Context) ([]*model.Feed, error) { Title: dbFeed.Title, FetchedAt: dbFeed.FetchedAt, IsSubscribed: dbFeed.IsSubscribed == 1, + UnreadCount: int32(countMap[dbFeed.ID]), }) } @@ -326,69 +337,13 @@ func (r *queryResolver) Feeds(ctx context.Context) ([]*model.Feed, error) { } // UnreadArticles is the resolver for the unreadArticles field. -func (r *queryResolver) UnreadArticles(ctx context.Context) ([]*model.Article, error) { - userID, err := getUserIDFromContext(ctx) - if err != nil { - return nil, err - } - - rows, err := r.Queries.GetUnreadArticles(ctx, userID) - if err != nil { - return nil, fmt.Errorf("failed to query unread articles: %w", err) - } - - var articles []*model.Article - for _, row := range rows { - articles = append(articles, &model.Article{ - ID: strconv.FormatInt(row.ID, 10), - FeedID: strconv.FormatInt(row.FeedID, 10), - GUID: row.Guid, - Title: row.Title, - URL: row.Url, - IsRead: row.IsRead == 1, - Feed: &model.Feed{ - ID: strconv.FormatInt(row.FeedID2, 10), - URL: row.FeedUrl, - Title: row.FeedTitle, - IsSubscribed: row.FeedIsSubscribed == 1, - }, - }) - } - - return articles, nil +func (r *queryResolver) UnreadArticles(ctx context.Context, feedID *string, after *string, first *int32) (*model.ArticleConnection, error) { + return r.paginatedArticles(ctx, 0, feedID, after, first) } // ReadArticles is the resolver for the readArticles field. -func (r *queryResolver) ReadArticles(ctx context.Context) ([]*model.Article, error) { - userID, err := getUserIDFromContext(ctx) - if err != nil { - return nil, err - } - - rows, err := r.Queries.GetReadArticles(ctx, userID) - if err != nil { - return nil, fmt.Errorf("failed to query read articles: %w", err) - } - - var articles []*model.Article - for _, row := range rows { - articles = append(articles, &model.Article{ - ID: strconv.FormatInt(row.ID, 10), - FeedID: strconv.FormatInt(row.FeedID, 10), - GUID: row.Guid, - Title: row.Title, - URL: row.Url, - IsRead: row.IsRead == 1, - Feed: &model.Feed{ - ID: strconv.FormatInt(row.FeedID2, 10), - URL: row.FeedUrl, - Title: row.FeedTitle, - IsSubscribed: row.FeedIsSubscribed == 1, - }, - }) - } - - return articles, nil +func (r *queryResolver) ReadArticles(ctx context.Context, feedID *string, after *string, first *int32) (*model.ArticleConnection, error) { + return r.paginatedArticles(ctx, 1, feedID, after, first) } // Feed is the resolver for the feed field. |
