diff --git a/server/router/api/v1/memo_service.go b/server/router/api/v1/memo_service.go
index 48323ca49..497be456c 100644
--- a/server/router/api/v1/memo_service.go
+++ b/server/router/api/v1/memo_service.go
@@ -297,18 +297,22 @@ func (s *APIV1Service) ListMemos(ctx context.Context, request *v1pb.ListMemosReq
}
// RELATIONS (batch load to avoid N+1)
- relationMap, err := s.batchConvertMemoRelations(ctx, memos)
+ relationMap, err := s.batchConvertMemoRelations(ctx, memos, false)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to batch load memo relations")
}
- creatorIDs := make([]int32, 0, len(memos))
+ creatorIDs := make([]int32, 0, len(memos)+len(reactions))
for _, memo := range memos {
creatorIDs = append(creatorIDs, memo.CreatorID)
}
+ for _, reaction := range reactions {
+ creatorIDs = append(creatorIDs, reaction.CreatorID)
+ }
creatorMap, err := s.listUsersByID(ctx, creatorIDs)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to list memo creators: %v", err)
}
+ conversionOptions := memoConversionOptions{displayWithUpdateTime: instanceMemoRelatedSetting.DisplayWithUpdateTime}
for _, memo := range memos {
memoName := fmt.Sprintf("%s%s", MemoNamePrefix, memo.UID)
@@ -316,7 +320,7 @@ func (s *APIV1Service) ListMemos(ctx context.Context, request *v1pb.ListMemosReq
attachments := attachmentMap[memo.ID]
relations := relationMap[memo.ID]
- memoMessage, err := s.convertMemoFromStoreWithCreators(ctx, memo, reactions, attachments, relations, creatorMap)
+ memoMessage, err := s.convertMemoFromStoreWithCreatorsAndOptions(ctx, memo, reactions, attachments, relations, creatorMap, conversionOptions)
if err != nil {
if stderrors.Is(err, errMemoCreatorNotFound) {
slog.Warn("Skipping memo with missing creator",
@@ -798,18 +802,26 @@ func (s *APIV1Service) ListMemoComments(ctx context.Context, request *v1pb.ListM
}
// RELATIONS (batch load to avoid N+1)
- relationMap, err := s.batchConvertMemoRelations(ctx, memos)
+ relationMap, err := s.batchConvertMemoRelations(ctx, memos, false)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to batch load memo relations")
}
- creatorIDs := make([]int32, 0, len(memos))
+ creatorIDs := make([]int32, 0, len(memos)+len(reactions))
for _, memo := range memos {
creatorIDs = append(creatorIDs, memo.CreatorID)
}
+ for _, reaction := range reactions {
+ creatorIDs = append(creatorIDs, reaction.CreatorID)
+ }
creatorMap, err := s.listUsersByID(ctx, creatorIDs)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to list memo creators: %v", err)
}
+ instanceMemoRelatedSetting, err := s.Store.GetInstanceMemoRelatedSetting(ctx)
+ if err != nil {
+ return nil, status.Errorf(codes.Internal, "failed to get instance memo related setting")
+ }
+ conversionOptions := memoConversionOptions{displayWithUpdateTime: instanceMemoRelatedSetting.DisplayWithUpdateTime}
var memosResponse []*v1pb.Memo
for _, m := range memos {
@@ -818,7 +830,7 @@ func (s *APIV1Service) ListMemoComments(ctx context.Context, request *v1pb.ListM
attachments := attachmentMap[m.ID]
relations := relationMap[m.ID]
- memoMessage, err := s.convertMemoFromStoreWithCreators(ctx, m, reactions, attachments, relations, creatorMap)
+ memoMessage, err := s.convertMemoFromStoreWithCreatorsAndOptions(ctx, m, reactions, attachments, relations, creatorMap, conversionOptions)
if err != nil {
if stderrors.Is(err, errMemoCreatorNotFound) {
slog.Warn("Skipping memo comment with missing creator",
diff --git a/server/router/api/v1/memo_service_converter.go b/server/router/api/v1/memo_service_converter.go
index 8d90f9e95..936baff43 100644
--- a/server/router/api/v1/memo_service_converter.go
+++ b/server/router/api/v1/memo_service_converter.go
@@ -20,6 +20,10 @@ var (
errReactionCreatorNotFound = stderrors.New("reaction creator not found")
)
+type memoConversionOptions struct {
+ displayWithUpdateTime bool
+}
+
func (s *APIV1Service) convertMemoFromStore(ctx context.Context, memo *store.Memo, reactions []*store.Reaction, attachments []*store.Attachment, relations []*v1pb.MemoRelation) (*v1pb.Memo, error) {
creatorMap, err := s.listUsersByID(ctx, []int32{memo.CreatorID})
if err != nil {
@@ -29,12 +33,24 @@ func (s *APIV1Service) convertMemoFromStore(ctx context.Context, memo *store.Mem
}
func (s *APIV1Service) convertMemoFromStoreWithCreators(ctx context.Context, memo *store.Memo, reactions []*store.Reaction, attachments []*store.Attachment, relations []*v1pb.MemoRelation, creatorMap map[int32]*store.User) (*v1pb.Memo, error) {
- displayTs := memo.CreatedTs
instanceMemoRelatedSetting, err := s.Store.GetInstanceMemoRelatedSetting(ctx)
if err != nil {
return nil, errors.Wrap(err, "failed to get instance memo related setting")
}
- if instanceMemoRelatedSetting.DisplayWithUpdateTime {
+ return s.convertMemoFromStoreWithCreatorsAndOptions(
+ ctx,
+ memo,
+ reactions,
+ attachments,
+ relations,
+ creatorMap,
+ memoConversionOptions{displayWithUpdateTime: instanceMemoRelatedSetting.DisplayWithUpdateTime},
+ )
+}
+
+func (s *APIV1Service) convertMemoFromStoreWithCreatorsAndOptions(ctx context.Context, memo *store.Memo, reactions []*store.Reaction, attachments []*store.Attachment, relations []*v1pb.MemoRelation, creatorMap map[int32]*store.User, options memoConversionOptions) (*v1pb.Memo, error) {
+ displayTs := memo.CreatedTs
+ if options.displayWithUpdateTime {
displayTs = memo.UpdatedTs
}
@@ -65,10 +81,11 @@ func (s *APIV1Service) convertMemoFromStoreWithCreators(ctx context.Context, mem
memoMessage.Parent = &parentName
}
- memoMessage.Reactions, err = s.convertReactionsFromStoreWithCreators(ctx, reactions, creatorMap)
+ reactionMessages, err := s.convertReactionsFromStoreWithCreators(ctx, reactions, creatorMap)
if err != nil {
return nil, errors.Wrap(err, "failed to convert reactions")
}
+ memoMessage.Reactions = reactionMessages
if relations != nil {
memoMessage.Relations = relations
@@ -179,7 +196,7 @@ func convertReactionFromStoreWithCreators(reaction *store.Reaction, creatorsByID
// batchConvertMemoRelations batch-loads relations for a list of memos and returns
// a map from memo ID to its converted relations. This avoids N+1 queries when listing memos.
-func (s *APIV1Service) batchConvertMemoRelations(ctx context.Context, memos []*store.Memo) (map[int32][]*v1pb.MemoRelation, error) {
+func (s *APIV1Service) batchConvertMemoRelations(ctx context.Context, memos []*store.Memo, includeSnippets bool) (map[int32][]*v1pb.MemoRelation, error) {
if len(memos) == 0 {
return map[int32][]*v1pb.MemoRelation{}, nil
}
@@ -202,14 +219,21 @@ func (s *APIV1Service) batchConvertMemoRelations(ctx context.Context, memos []*s
memoIDSet[m.ID] = true
}
- // Single batch query to get all relations involving any of these memos.
- allRelations, err := s.Store.ListMemoRelations(ctx, &store.FindMemoRelation{
- MemoIDList: memoIDs,
- MemoFilter: &memoFilter,
+ outgoingRelations, err := s.Store.ListMemoRelations(ctx, &store.FindMemoRelation{
+ SourceMemoIDList: memoIDs,
+ MemoFilter: &memoFilter,
+ })
+ if err != nil {
+ return nil, errors.Wrap(err, "failed to batch list outgoing memo relations")
+ }
+ incomingRelations, err := s.Store.ListMemoRelations(ctx, &store.FindMemoRelation{
+ RelatedMemoIDList: memoIDs,
+ MemoFilter: &memoFilter,
})
if err != nil {
- return nil, errors.Wrap(err, "failed to batch list memo relations")
+ return nil, errors.Wrap(err, "failed to batch list incoming memo relations")
}
+ allRelations := mergeMemoRelations(outgoingRelations, incomingRelations)
// Collect all memo IDs referenced in relations that we need to resolve.
neededIDs := make(map[int32]bool)
@@ -220,10 +244,16 @@ func (s *APIV1Service) batchConvertMemoRelations(ctx context.Context, memos []*s
// Build ID→UID map from the memos we already have.
memoIDToUID := make(map[int32]string, len(memos))
- memoIDToContent := make(map[int32]string, len(memos))
+ memoIDToSnippet := make(map[int32]string, len(memos))
for _, m := range memos {
memoIDToUID[m.ID] = m.UID
- memoIDToContent[m.ID] = m.Content
+ if includeSnippets {
+ snippet, err := s.getMemoContentSnippet(m.Content)
+ if err != nil {
+ return nil, errors.Wrap(err, "failed to get memo content snippet")
+ }
+ memoIDToSnippet[m.ID] = snippet
+ }
delete(neededIDs, m.ID)
}
@@ -233,13 +263,20 @@ func (s *APIV1Service) batchConvertMemoRelations(ctx context.Context, memos []*s
for id := range neededIDs {
extraIDs = append(extraIDs, id)
}
- extraMemos, err := s.Store.ListMemos(ctx, &store.FindMemo{IDList: extraIDs})
+ extraFind := &store.FindMemo{IDList: extraIDs, ExcludeContent: !includeSnippets}
+ extraMemos, err := s.Store.ListMemos(ctx, extraFind)
if err != nil {
return nil, errors.Wrap(err, "failed to batch fetch related memos")
}
for _, m := range extraMemos {
memoIDToUID[m.ID] = m.UID
- memoIDToContent[m.ID] = m.Content
+ if includeSnippets {
+ snippet, err := s.getMemoContentSnippet(m.Content)
+ if err != nil {
+ return nil, errors.Wrap(err, "failed to get related memo content snippet")
+ }
+ memoIDToSnippet[m.ID] = snippet
+ }
}
}
@@ -252,16 +289,14 @@ func (s *APIV1Service) batchConvertMemoRelations(ctx context.Context, memos []*s
continue
}
- memoSnippet, _ := s.getMemoContentSnippet(memoIDToContent[r.MemoID])
- relatedSnippet, _ := s.getMemoContentSnippet(memoIDToContent[r.RelatedMemoID])
relation := &v1pb.MemoRelation{
Memo: &v1pb.MemoRelation_Memo{
Name: fmt.Sprintf("%s%s", MemoNamePrefix, memoUID),
- Snippet: memoSnippet,
+ Snippet: memoIDToSnippet[r.MemoID],
},
RelatedMemo: &v1pb.MemoRelation_Memo{
Name: fmt.Sprintf("%s%s", MemoNamePrefix, relatedUID),
- Snippet: relatedSnippet,
+ Snippet: memoIDToSnippet[r.RelatedMemoID],
},
Type: convertMemoRelationTypeFromStore(r.Type),
}
@@ -280,13 +315,29 @@ func (s *APIV1Service) batchConvertMemoRelations(ctx context.Context, memos []*s
// loadMemoRelations loads relations for a single memo and converts them to API format.
func (s *APIV1Service) loadMemoRelations(ctx context.Context, memo *store.Memo) ([]*v1pb.MemoRelation, error) {
- relationMap, err := s.batchConvertMemoRelations(ctx, []*store.Memo{memo})
+ relationMap, err := s.batchConvertMemoRelations(ctx, []*store.Memo{memo}, true)
if err != nil {
return nil, err
}
return relationMap[memo.ID], nil
}
+func mergeMemoRelations(groups ...[]*store.MemoRelation) []*store.MemoRelation {
+ seen := make(map[string]struct{})
+ merged := make([]*store.MemoRelation, 0)
+ for _, relations := range groups {
+ for _, relation := range relations {
+ key := fmt.Sprintf("%d:%d:%s", relation.MemoID, relation.RelatedMemoID, relation.Type)
+ if _, ok := seen[key]; ok {
+ continue
+ }
+ seen[key] = struct{}{}
+ merged = append(merged, relation)
+ }
+ }
+ return merged
+}
+
func convertMemoPropertyFromStore(property *storepb.MemoPayload_Property) *v1pb.Memo_Property {
if property == nil {
return nil
diff --git a/server/router/api/v1/memo_share_service.go b/server/router/api/v1/memo_share_service.go
index 945e1a194..3784c74ee 100644
--- a/server/router/api/v1/memo_share_service.go
+++ b/server/router/api/v1/memo_share_service.go
@@ -176,7 +176,7 @@ func (s *APIV1Service) GetMemoByShare(ctx context.Context, request *v1pb.GetMemo
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to list attachments")
}
- relations, err := s.batchConvertMemoRelations(ctx, []*store.Memo{memo})
+ relations, err := s.batchConvertMemoRelations(ctx, []*store.Memo{memo}, true)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to load memo relations")
}
diff --git a/server/router/api/v1/test/memo_service_benchmark_test.go b/server/router/api/v1/test/memo_service_benchmark_test.go
new file mode 100644
index 000000000..e880317bf
--- /dev/null
+++ b/server/router/api/v1/test/memo_service_benchmark_test.go
@@ -0,0 +1,282 @@
+package test
+
+import (
+ "context"
+ "fmt"
+ "path/filepath"
+ "testing"
+
+ "github.com/usememos/memos/internal/profile"
+ "github.com/usememos/memos/internal/version"
+ v1pb "github.com/usememos/memos/proto/gen/api/v1"
+ "github.com/usememos/memos/server/auth"
+ apiv1 "github.com/usememos/memos/server/router/api/v1"
+ "github.com/usememos/memos/store"
+ "github.com/usememos/memos/store/db"
+)
+
+const (
+ benchmarkTopLevelMemoCount = 5000
+ benchmarkPageSize = 16
+)
+
+type benchmarkService struct {
+ *TestService
+ hostUser *store.User
+ authenticatedCtx context.Context
+ publicCtx context.Context
+ pageTenToken string
+ commentParentName string
+}
+
+func newBenchmarkService(tb testing.TB) *benchmarkService {
+ tb.Helper()
+
+ ctx := context.Background()
+ testService := newTestingServiceForTB(tb)
+
+ hostUser, err := testService.CreateHostUser(ctx, "bench-host")
+ if err != nil {
+ tb.Fatalf("failed to create host user: %v", err)
+ }
+
+ commentParentName, err := seedListMemosBenchmarkData(ctx, testService.Store, hostUser)
+ if err != nil {
+ tb.Fatalf("failed to seed benchmark data: %v", err)
+ }
+
+ authenticatedCtx := context.WithValue(context.Background(), auth.UserIDContextKey, hostUser.ID)
+ pageTenToken, err := getListMemosPageToken(authenticatedCtx, testService.Service, 10, benchmarkPageSize)
+ if err != nil {
+ tb.Fatalf("failed to build page token: %v", err)
+ }
+
+ return &benchmarkService{
+ TestService: testService,
+ hostUser: hostUser,
+ authenticatedCtx: authenticatedCtx,
+ publicCtx: context.Background(),
+ pageTenToken: pageTenToken,
+ commentParentName: commentParentName,
+ }
+}
+
+func newTestingServiceForTB(tb testing.TB) *TestService {
+ tb.Helper()
+
+ ctx := context.Background()
+ dataDir := tb.TempDir()
+ testProfile := getBenchmarkProfile(dataDir)
+ dbDriver, err := db.NewDBDriver(testProfile)
+ if err != nil {
+ tb.Fatalf("failed to create db driver: %v", err)
+ }
+
+ testStore := store.New(dbDriver, testProfile)
+ if err := testStore.Migrate(ctx); err != nil {
+ tb.Fatalf("failed to migrate db: %v", err)
+ }
+ tb.Cleanup(func() {
+ testStore.Close()
+ })
+
+ service := newServiceWithProfile(testProfile, testStore)
+ return &TestService{
+ Service: service,
+ Store: testStore,
+ Profile: testProfile,
+ Secret: service.Secret,
+ }
+}
+
+func getBenchmarkProfile(dataDir string) *profile.Profile {
+ return &profile.Profile{
+ Demo: true,
+ Version: version.GetCurrentVersion(),
+ InstanceURL: "http://localhost:8080",
+ Driver: "sqlite",
+ DSN: filepath.Join(dataDir, "bench.db"),
+ Data: dataDir,
+ }
+}
+
+func newServiceWithProfile(testProfile *profile.Profile, testStore *store.Store) *apiv1.APIV1Service {
+ service := apiv1.NewAPIV1Service("bench-secret", testProfile, testStore)
+ return service
+}
+
+func seedListMemosBenchmarkData(ctx context.Context, stores *store.Store, hostUser *store.User) (string, error) {
+ topLevelMemos := make([]*store.Memo, 0, benchmarkTopLevelMemoCount)
+ commentParentName := ""
+
+ for i := 0; i < benchmarkTopLevelMemoCount; i++ {
+ visibility := store.Private
+ if i%4 == 0 {
+ visibility = store.Public
+ }
+ memo, err := stores.CreateMemo(ctx, &store.Memo{
+ UID: fmt.Sprintf("memo-%06d", i),
+ CreatorID: hostUser.ID,
+ Content: benchmarkMemoContent(i),
+ Visibility: visibility,
+ })
+ if err != nil {
+ return "", err
+ }
+ topLevelMemos = append(topLevelMemos, memo)
+
+ if i%3 == 0 {
+ if _, err := stores.CreateAttachment(ctx, &store.Attachment{
+ UID: fmt.Sprintf("att-%06d", i),
+ CreatorID: hostUser.ID,
+ Filename: fmt.Sprintf("memo-%06d.png", i),
+ Type: "image/png",
+ Size: 2048,
+ MemoID: &memo.ID,
+ }); err != nil {
+ return "", err
+ }
+ }
+
+ if i%5 == 0 {
+ if _, err := stores.UpsertReaction(ctx, &store.Reaction{
+ CreatorID: hostUser.ID,
+ ContentID: "memos/" + memo.UID,
+ ReactionType: "thumbs-up",
+ }); err != nil {
+ return "", err
+ }
+ }
+ }
+
+ for i, memo := range topLevelMemos {
+ if i+1 < len(topLevelMemos) && i%4 == 0 {
+ if _, err := stores.UpsertMemoRelation(ctx, &store.MemoRelation{
+ MemoID: memo.ID,
+ RelatedMemoID: topLevelMemos[i+1].ID,
+ Type: store.MemoRelationReference,
+ }); err != nil {
+ return "", err
+ }
+ }
+
+ if i%6 == 0 {
+ commentMemo, err := stores.CreateMemo(ctx, &store.Memo{
+ UID: fmt.Sprintf("comment-%06d", i),
+ CreatorID: hostUser.ID,
+ Content: fmt.Sprintf("Comment for memo %06d", i),
+ Visibility: store.Private,
+ })
+ if err != nil {
+ return "", err
+ }
+ if _, err := stores.UpsertMemoRelation(ctx, &store.MemoRelation{
+ MemoID: commentMemo.ID,
+ RelatedMemoID: memo.ID,
+ Type: store.MemoRelationComment,
+ }); err != nil {
+ return "", err
+ }
+ if commentParentName == "" {
+ commentParentName = "memos/" + memo.UID
+ }
+ }
+ }
+
+ return commentParentName, nil
+}
+
+func benchmarkMemoContent(i int) string {
+ return fmt.Sprintf("# Bench Memo %06d\n\nThis is benchmark memo %06d with enough content to exercise snippet generation.\n\n- task one\n- task two\n", i, i)
+}
+
+func getListMemosPageToken(ctx context.Context, service *apiv1.APIV1Service, page int, pageSize int32) (string, error) {
+ pageToken := ""
+ for range page - 1 {
+ resp, err := service.ListMemos(ctx, &v1pb.ListMemosRequest{
+ PageSize: pageSize,
+ PageToken: pageToken,
+ })
+ if err != nil {
+ return "", err
+ }
+ pageToken = resp.NextPageToken
+ if pageToken == "" {
+ break
+ }
+ }
+ return pageToken, nil
+}
+
+func BenchmarkListMemos(b *testing.B) {
+ bench := newBenchmarkService(b)
+
+ b.Run("authenticated_first_page", func(b *testing.B) {
+ req := &v1pb.ListMemosRequest{PageSize: benchmarkPageSize}
+ b.ReportAllocs()
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ resp, err := bench.Service.ListMemos(bench.authenticatedCtx, req)
+ if err != nil {
+ b.Fatalf("ListMemos failed: %v", err)
+ }
+ if len(resp.Memos) == 0 {
+ b.Fatal("expected memos in authenticated benchmark response")
+ }
+ }
+ })
+
+ b.Run("authenticated_page_ten", func(b *testing.B) {
+ req := &v1pb.ListMemosRequest{PageSize: benchmarkPageSize, PageToken: bench.pageTenToken}
+ b.ReportAllocs()
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ resp, err := bench.Service.ListMemos(bench.authenticatedCtx, req)
+ if err != nil {
+ b.Fatalf("ListMemos failed: %v", err)
+ }
+ if len(resp.Memos) == 0 {
+ b.Fatal("expected memos in paged benchmark response")
+ }
+ }
+ })
+
+ b.Run("public_first_page", func(b *testing.B) {
+ req := &v1pb.ListMemosRequest{PageSize: benchmarkPageSize}
+ b.ReportAllocs()
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ resp, err := bench.Service.ListMemos(bench.publicCtx, req)
+ if err != nil {
+ b.Fatalf("ListMemos failed: %v", err)
+ }
+ if len(resp.Memos) == 0 {
+ b.Fatal("expected memos in public benchmark response")
+ }
+ }
+ })
+}
+
+func BenchmarkListMemoCommentsPreview(b *testing.B) {
+ bench := newBenchmarkService(b)
+ if bench.commentParentName == "" {
+ b.Fatal("expected seeded memo with comments")
+ }
+
+ req := &v1pb.ListMemoCommentsRequest{
+ Name: bench.commentParentName,
+ PageSize: 3,
+ }
+
+ b.ReportAllocs()
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ resp, err := bench.Service.ListMemoComments(bench.authenticatedCtx, req)
+ if err != nil {
+ b.Fatalf("ListMemoComments failed: %v", err)
+ }
+ if len(resp.Memos) == 0 {
+ b.Fatal("expected comments in benchmark response")
+ }
+ }
+}
diff --git a/store/db/mysql/memo_relation.go b/store/db/mysql/memo_relation.go
index 1edd517bb..57a9ad49d 100644
--- a/store/db/mysql/memo_relation.go
+++ b/store/db/mysql/memo_relation.go
@@ -54,6 +54,22 @@ func (d *DB) ListMemoRelations(ctx context.Context, find *store.FindMemoRelation
}
where = append(where, fmt.Sprintf("(`memo_id` IN (%s) OR `related_memo_id` IN (%s))", inClause, inClause))
}
+ if len(find.SourceMemoIDList) > 0 {
+ placeholders := make([]string, len(find.SourceMemoIDList))
+ for i, id := range find.SourceMemoIDList {
+ placeholders[i] = "?"
+ args = append(args, id)
+ }
+ where = append(where, fmt.Sprintf("`memo_id` IN (%s)", strings.Join(placeholders, ", ")))
+ }
+ if len(find.RelatedMemoIDList) > 0 {
+ placeholders := make([]string, len(find.RelatedMemoIDList))
+ for i, id := range find.RelatedMemoIDList {
+ placeholders[i] = "?"
+ args = append(args, id)
+ }
+ where = append(where, fmt.Sprintf("`related_memo_id` IN (%s)", strings.Join(placeholders, ", ")))
+ }
if find.MemoFilter != nil {
engine, err := filter.DefaultEngine()
if err != nil {
diff --git a/store/db/postgres/memo_relation.go b/store/db/postgres/memo_relation.go
index ec734c53a..316b95aa0 100644
--- a/store/db/postgres/memo_relation.go
+++ b/store/db/postgres/memo_relation.go
@@ -50,18 +50,34 @@ func (d *DB) ListMemoRelations(ctx context.Context, find *store.FindMemoRelation
where, args = append(where, "type = "+placeholder(len(args)+1)), append(args, *find.Type)
}
if len(find.MemoIDList) > 0 {
- memoPlaceholders := make([]string, len(find.MemoIDList))
+ placeholders := make([]string, len(find.MemoIDList))
for i, id := range find.MemoIDList {
- memoPlaceholders[i] = placeholder(len(args) + 1)
+ placeholders[i] = placeholder(len(args) + 1)
args = append(args, id)
}
+ inClause := strings.Join(placeholders, ", ")
relatedPlaceholders := make([]string, len(find.MemoIDList))
for i, id := range find.MemoIDList {
relatedPlaceholders[i] = placeholder(len(args) + 1)
args = append(args, id)
}
- where = append(where, fmt.Sprintf("(memo_id IN (%s) OR related_memo_id IN (%s))",
- strings.Join(memoPlaceholders, ", "), strings.Join(relatedPlaceholders, ", ")))
+ where = append(where, fmt.Sprintf("(memo_id IN (%s) OR related_memo_id IN (%s))", inClause, strings.Join(relatedPlaceholders, ", ")))
+ }
+ if len(find.SourceMemoIDList) > 0 {
+ placeholders := make([]string, len(find.SourceMemoIDList))
+ for i, id := range find.SourceMemoIDList {
+ placeholders[i] = placeholder(len(args) + 1)
+ args = append(args, id)
+ }
+ where = append(where, fmt.Sprintf("memo_id IN (%s)", strings.Join(placeholders, ", ")))
+ }
+ if len(find.RelatedMemoIDList) > 0 {
+ placeholders := make([]string, len(find.RelatedMemoIDList))
+ for i, id := range find.RelatedMemoIDList {
+ placeholders[i] = placeholder(len(args) + 1)
+ args = append(args, id)
+ }
+ where = append(where, fmt.Sprintf("related_memo_id IN (%s)", strings.Join(placeholders, ", ")))
}
if find.MemoFilter != nil {
engine, err := filter.DefaultEngine()
diff --git a/store/db/sqlite/memo_relation.go b/store/db/sqlite/memo_relation.go
index da8ea36ab..9a691c121 100644
--- a/store/db/sqlite/memo_relation.go
+++ b/store/db/sqlite/memo_relation.go
@@ -56,12 +56,27 @@ func (d *DB) ListMemoRelations(ctx context.Context, find *store.FindMemoRelation
args = append(args, id)
}
inClause := strings.Join(placeholders, ", ")
- // Duplicate args for the second IN clause.
for _, id := range find.MemoIDList {
args = append(args, id)
}
where = append(where, fmt.Sprintf("(memo_id IN (%s) OR related_memo_id IN (%s))", inClause, inClause))
}
+ if len(find.SourceMemoIDList) > 0 {
+ placeholders := make([]string, len(find.SourceMemoIDList))
+ for i, id := range find.SourceMemoIDList {
+ placeholders[i] = "?"
+ args = append(args, id)
+ }
+ where = append(where, fmt.Sprintf("memo_id IN (%s)", strings.Join(placeholders, ", ")))
+ }
+ if len(find.RelatedMemoIDList) > 0 {
+ placeholders := make([]string, len(find.RelatedMemoIDList))
+ for i, id := range find.RelatedMemoIDList {
+ placeholders[i] = "?"
+ args = append(args, id)
+ }
+ where = append(where, fmt.Sprintf("related_memo_id IN (%s)", strings.Join(placeholders, ", ")))
+ }
if find.MemoFilter != nil {
engine, err := filter.DefaultEngine()
if err != nil {
diff --git a/store/memo_relation.go b/store/memo_relation.go
index d341c9b0b..12a448665 100644
--- a/store/memo_relation.go
+++ b/store/memo_relation.go
@@ -26,8 +26,12 @@ type FindMemoRelation struct {
MemoFilter *string
// MemoIDList matches relations where memo_id OR related_memo_id is in the list.
MemoIDList []int32
- Limit *int
- Offset *int
+ // SourceMemoIDList matches relations where memo_id is in the list.
+ SourceMemoIDList []int32
+ // RelatedMemoIDList matches relations where related_memo_id is in the list.
+ RelatedMemoIDList []int32
+ Limit *int
+ Offset *int
}
type DeleteMemoRelation struct {
diff --git a/web/src/components/MemoMetadata/Relation/RelationListView.tsx b/web/src/components/MemoMetadata/Relation/RelationListView.tsx
index aa2f6e171..8b38c4073 100644
--- a/web/src/components/MemoMetadata/Relation/RelationListView.tsx
+++ b/web/src/components/MemoMetadata/Relation/RelationListView.tsx
@@ -5,6 +5,7 @@ import type { MemoRelation } from "@/types/proto/api/v1/memo_service_pb";
import { useTranslate } from "@/utils/i18n";
import RelationCard from "./RelationCard";
import { getRelationBuckets, getRelationMemo, getRelationMemoName, type RelationDirection } from "./relationHelpers";
+import { useResolvedRelationMemos } from "./useResolvedRelationMemos";
interface RelationListViewProps {
relations: MemoRelation[];
@@ -16,6 +17,7 @@ interface RelationListViewProps {
function RelationListView({ relations, currentMemoName, parentPage, className }: RelationListViewProps) {
const t = useTranslate();
const [activeTab, setActiveTab] = useState<"referencing" | "referenced">("referencing");
+ const resolvedMemos = useResolvedRelationMemos(relations);
const { referencing: referencingRelations, referenced: referencedRelations } = useMemo(
() => getRelationBuckets(relations, currentMemoName),
@@ -60,9 +62,15 @@ function RelationListView({ relations, currentMemoName, parentPage, className }:
}
contentClassName="flex flex-col gap-0 p-1.5"
>
- {activeRelations.map((relation) => (
-
- ))}
+ {activeRelations.map((relation) => {
+ const memo = getRelationMemo(relation, direction);
+ if (!memo) {
+ return null;
+ }
+ return (
+
+ );
+ })}
);
}
diff --git a/web/src/components/MemoMetadata/Relation/useResolvedRelationMemos.ts b/web/src/components/MemoMetadata/Relation/useResolvedRelationMemos.ts
index 4a505bf7a..9d422b930 100644
--- a/web/src/components/MemoMetadata/Relation/useResolvedRelationMemos.ts
+++ b/web/src/components/MemoMetadata/Relation/useResolvedRelationMemos.ts
@@ -11,9 +11,10 @@ export const useResolvedRelationMemos = (relations: MemoRelation[]) => {
const names = new Set();
for (const relation of relations) {
- const relatedMemo = relation.relatedMemo;
- if (relatedMemo?.name && !relatedMemo.snippet && !resolvedMemos[relatedMemo.name]) {
- names.add(relatedMemo.name);
+ for (const memo of [relation.memo, relation.relatedMemo]) {
+ if (memo?.name && !memo.snippet && !resolvedMemos[memo.name]) {
+ names.add(memo.name);
+ }
}
}
diff --git a/web/src/components/MemoView/components/MemoCommentListView.tsx b/web/src/components/MemoView/components/MemoCommentListView.tsx
index c416449b5..9654bb0e7 100644
--- a/web/src/components/MemoView/components/MemoCommentListView.tsx
+++ b/web/src/components/MemoView/components/MemoCommentListView.tsx
@@ -10,7 +10,7 @@ const MemoCommentListView: React.FC = () => {
const { memo } = useMemoViewContext();
const { isInMemoDetailPage, commentAmount } = useMemoViewDerived();
- const { data } = useMemoComments(memo.name, { enabled: !isInMemoDetailPage && commentAmount > 0 });
+ const { data } = useMemoComments(memo.name, { enabled: !isInMemoDetailPage && commentAmount > 0, pageSize: 3 });
const comments = data?.memos ?? [];
const displayedComments = comments.slice(0, 3);
const { data: commentCreators } = useUsersByNames(displayedComments.map((comment) => comment.creator));
diff --git a/web/src/hooks/useMemoQueries.ts b/web/src/hooks/useMemoQueries.ts
index 963a9d15d..9fda043f9 100644
--- a/web/src/hooks/useMemoQueries.ts
+++ b/web/src/hooks/useMemoQueries.ts
@@ -5,7 +5,7 @@ import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tansta
import { memoServiceClient } from "@/connect";
import { userKeys } from "@/hooks/useUserQueries";
import type { ListMemosRequest, ListMemosResponse, Memo } from "@/types/proto/api/v1/memo_service_pb";
-import { ListMemosRequestSchema, MemoSchema } from "@/types/proto/api/v1/memo_service_pb";
+import { ListMemoCommentsRequestSchema, ListMemosRequestSchema, MemoSchema } from "@/types/proto/api/v1/memo_service_pb";
// Query keys factory for consistent cache management
export const memoKeys = {
@@ -243,11 +243,16 @@ export function useDeleteMemo() {
});
}
-export function useMemoComments(name: string, options?: { enabled?: boolean }) {
+export function useMemoComments(name: string, options?: { enabled?: boolean; pageSize?: number }) {
return useQuery({
- queryKey: memoKeys.comments(name),
+ queryKey: [...memoKeys.comments(name), options?.pageSize ?? 0],
queryFn: async () => {
- const response = await memoServiceClient.listMemoComments({ name });
+ const response = await memoServiceClient.listMemoComments(
+ create(ListMemoCommentsRequestSchema, {
+ name,
+ pageSize: options?.pageSize ?? 0,
+ }),
+ );
return response;
},
enabled: options?.enabled ?? true,