internal/repo/search_common/search_repo.go (505 lines of code) (raw):

/* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ package search_common import ( "context" "fmt" "strconv" "strings" "time" tagcommon "github.com/apache/answer/internal/service/tag_common" "github.com/apache/answer/plugin" "github.com/apache/answer/pkg/htmltext" "github.com/apache/answer/internal/base/data" "github.com/apache/answer/internal/base/handler" "github.com/apache/answer/internal/base/reason" "github.com/apache/answer/internal/entity" "github.com/apache/answer/internal/schema" "github.com/apache/answer/internal/service/search_common" "github.com/apache/answer/internal/service/unique" usercommon "github.com/apache/answer/internal/service/user_common" "github.com/apache/answer/pkg/converter" "github.com/apache/answer/pkg/obj" "github.com/apache/answer/pkg/uid" "github.com/segmentfault/pacman/errors" "xorm.io/builder" ) var ( qFields = []string{ "`question`.`id`", "`question`.`id` as `question_id`", "`title`", "`parsed_text`", "`question`.`created_at` as `created_at`", "`user_id`", "`vote_count`", "`answer_count`", "CASE WHEN `accepted_answer_id` > 0 THEN 2 ELSE 0 END as `accepted`", "`question`.`status` as `status`", "`post_update_time`", } aFields = []string{ "`answer`.`id` as `id`", "`question_id`", "`question`.`title` as `title`", "`answer`.`parsed_text` as `parsed_text`", "`answer`.`created_at` as `created_at`", "`answer`.`user_id` as `user_id`", "`answer`.`vote_count` as `vote_count`", "0 as `answer_count`", "`adopted` as `accepted`", "`answer`.`status` as `status`", "`answer`.`created_at` as `post_update_time`", } ) // searchRepo tag repository type searchRepo struct { data *data.Data userCommon *usercommon.UserCommon uniqueIDRepo unique.UniqueIDRepo tagCommon *tagcommon.TagCommonService } // NewSearchRepo new repository func NewSearchRepo( data *data.Data, uniqueIDRepo unique.UniqueIDRepo, userCommon *usercommon.UserCommon, tagCommon *tagcommon.TagCommonService, ) search_common.SearchRepo { return &searchRepo{ data: data, uniqueIDRepo: uniqueIDRepo, userCommon: userCommon, tagCommon: tagCommon, } } // SearchContents search question and answer data func (sr *searchRepo) SearchContents(ctx context.Context, words []string, tagIDs [][]string, userID string, votes int, page, pageSize int, order string) (resp []*schema.SearchResult, total int64, err error) { words = filterWords(words) var ( b *builder.Builder ub *builder.Builder qfs = qFields afs = aFields argsQ = []interface{}{} argsA = []interface{}{} ) if order == "relevance" { if len(words) > 0 { qfs, argsQ = addRelevanceField([]string{"title", "original_text"}, words, qfs) afs, argsA = addRelevanceField([]string{"`answer`.`original_text`"}, words, afs) } else { order = "newest" } } b = builder.MySQL().Select(qfs...).From("`question`") ub = builder.MySQL().Select(afs...).From("`answer`"). LeftJoin("`question`", "`question`.id = `answer`.question_id") b.Where(builder.Lt{"`question`.`status`": entity.QuestionStatusDeleted}). And(builder.Eq{"`question`.`show`": entity.QuestionShow}) ub.Where(builder.Lt{"`question`.`status`": entity.QuestionStatusDeleted}). And(builder.Lt{"`answer`.`status`": entity.AnswerStatusDeleted}). And(builder.Eq{"`question`.`show`": entity.QuestionShow}) argsQ = append(argsQ, entity.QuestionStatusDeleted, entity.QuestionShow) argsA = append(argsA, entity.QuestionStatusDeleted, entity.AnswerStatusDeleted, entity.QuestionShow) likeConQ := builder.NewCond() likeConA := builder.NewCond() for _, word := range words { likeConQ = likeConQ.Or(builder.Like{"title", word}). Or(builder.Like{"original_text", word}) argsQ = append(argsQ, "%"+word+"%") argsQ = append(argsQ, "%"+word+"%") likeConA = likeConA.Or(builder.Like{"`answer`.original_text", word}) argsA = append(argsA, "%"+word+"%") } b.Where(likeConQ) ub.Where(likeConA) // check tag for ti, tagID := range tagIDs { ast := "tag_rel" + strconv.Itoa(ti) b.Join("INNER", "tag_rel as "+ast, "question.id = "+ast+".object_id"). And(builder.Eq{ ast + ".status": entity.TagRelStatusAvailable, }). And(builder.In(ast+".tag_id", tagID)) ub.Join("INNER", "tag_rel as "+ast, "question_id = "+ast+".object_id"). And(builder.Eq{ ast + ".status": entity.TagRelStatusAvailable, }). And(builder.In(ast+".tag_id", tagID)) argsQ = append(argsQ, entity.TagRelStatusAvailable) argsA = append(argsA, entity.TagRelStatusAvailable) for _, t := range tagID { argsQ = append(argsQ, t) argsA = append(argsA, t) } } // check user if userID != "" { b.Where(builder.Eq{"question.user_id": userID}) ub.Where(builder.Eq{"answer.user_id": userID}) argsQ = append(argsQ, userID) argsA = append(argsA, userID) } // check vote if votes == 0 { b.Where(builder.Eq{"question.vote_count": votes}) ub.Where(builder.Eq{"answer.vote_count": votes}) argsQ = append(argsQ, votes) argsA = append(argsA, votes) } else if votes > 0 { b.Where(builder.Gte{"question.vote_count": votes}) ub.Where(builder.Gte{"answer.vote_count": votes}) argsQ = append(argsQ, votes) argsA = append(argsA, votes) } //b = b.Union("all", ub) ubSQL, _, err := ub.ToSQL() if err != nil { return } bSQL, _, err := b.ToSQL() if err != nil { return } sql := fmt.Sprintf("(%s UNION ALL %s)", bSQL, ubSQL) countSQL, _, err := builder.MySQL().Select("count(*) total").From(sql, "c").ToSQL() if err != nil { return } startNum := (page - 1) * pageSize querySQL, _, err := builder.MySQL().Select("*").From(sql, "t").OrderBy(sr.parseOrder(ctx, order)).Limit(pageSize, startNum).ToSQL() if err != nil { return } queryArgs := []interface{}{} countArgs := []interface{}{} queryArgs = append(queryArgs, querySQL) queryArgs = append(queryArgs, argsQ...) queryArgs = append(queryArgs, argsA...) countArgs = append(countArgs, countSQL) countArgs = append(countArgs, argsQ...) countArgs = append(countArgs, argsA...) res, err := sr.data.DB.Context(ctx).Query(queryArgs...) if err != nil { return } tr, err := sr.data.DB.Context(ctx).Query(countArgs...) if len(tr) != 0 { total = converter.StringToInt64(string(tr[0]["total"])) } if err != nil { err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() return } else { resp, err = sr.parseResult(ctx, res, words) return } } // SearchQuestions search question data func (sr *searchRepo) SearchQuestions(ctx context.Context, words []string, tagIDs [][]string, notAccepted bool, views, answers int, page, pageSize int, order string) (resp []*schema.SearchResult, total int64, err error) { words = filterWords(words) var ( qfs = qFields args = []interface{}{} ) if order == "relevance" { if len(words) > 0 { qfs, args = addRelevanceField([]string{"title", "original_text"}, words, qfs) } else { order = "newest" } } b := builder.MySQL().Select(qfs...).From("question") b.Where(builder.Lt{"`question`.`status`": entity.QuestionStatusDeleted}).And(builder.Eq{"`question`.`show`": entity.QuestionShow}) args = append(args, entity.QuestionStatusDeleted, entity.QuestionShow) likeConQ := builder.NewCond() for _, word := range words { likeConQ = likeConQ.Or(builder.Like{"title", word}). Or(builder.Like{"original_text", word}) args = append(args, "%"+word+"%") args = append(args, "%"+word+"%") } b.Where(likeConQ) // check tag for ti, tagID := range tagIDs { ast := "tag_rel" + strconv.Itoa(ti) b.Join("INNER", "tag_rel as "+ast, "question.id = "+ast+".object_id"). And(builder.Eq{ ast + ".status": entity.TagRelStatusAvailable, }). And(builder.In(ast+".tag_id", tagID)) args = append(args, entity.TagRelStatusAvailable) for _, t := range tagID { args = append(args, t) } } // check need filter has not accepted if notAccepted { b.And(builder.Eq{"accepted_answer_id": 0}) args = append(args, 0) } // check views if views > -1 { b.And(builder.Gte{"view_count": views}) args = append(args, views) } // check answers if answers == 0 { b.And(builder.Eq{"answer_count": answers}) args = append(args, answers) } else if answers > 0 { b.And(builder.Gte{"answer_count": answers}) args = append(args, answers) } if answers == 0 { b.And(builder.Eq{"answer_count": 0}) args = append(args, 0) } else if answers > 0 { b.And(builder.Gte{"answer_count": answers}) args = append(args, answers) } queryArgs := []interface{}{} countArgs := []interface{}{} countSQL, _, err := builder.MySQL().Select("count(*) total").From(b, "c").ToSQL() if err != nil { return } startNum := (page - 1) * pageSize querySQL, _, err := b.OrderBy(sr.parseOrder(ctx, order)).Limit(pageSize, startNum).ToSQL() if err != nil { return } queryArgs = append(queryArgs, querySQL) queryArgs = append(queryArgs, args...) countArgs = append(countArgs, countSQL) countArgs = append(countArgs, args...) res, err := sr.data.DB.Context(ctx).Query(queryArgs...) if err != nil { return } tr, err := sr.data.DB.Context(ctx).Query(countArgs...) if err != nil { return } if len(tr) != 0 { total = converter.StringToInt64(string(tr[0]["total"])) } resp, err = sr.parseResult(ctx, res, words) if err != nil { err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() } return } // SearchAnswers search answer data func (sr *searchRepo) SearchAnswers(ctx context.Context, words []string, tagIDs [][]string, accepted bool, questionID string, page, pageSize int, order string) (resp []*schema.SearchResult, total int64, err error) { words = filterWords(words) var ( afs = aFields args = []interface{}{} ) if order == "relevance" { if len(words) > 0 { afs, args = addRelevanceField([]string{"`answer`.`original_text`"}, words, afs) } else { order = "newest" } } b := builder.MySQL().Select(afs...).From("`answer`"). LeftJoin("`question`", "`question`.id = `answer`.question_id") b.Where(builder.Lt{"`question`.`status`": entity.QuestionStatusDeleted}). And(builder.Lt{"`answer`.`status`": entity.AnswerStatusDeleted}).And(builder.Eq{"`question`.`show`": entity.QuestionShow}) args = append(args, entity.QuestionStatusDeleted, entity.AnswerStatusDeleted, entity.QuestionShow) likeConA := builder.NewCond() for _, word := range words { likeConA = likeConA.Or(builder.Like{"`answer`.original_text", word}) args = append(args, "%"+word+"%") } b.Where(likeConA) // check tag for ti, tagID := range tagIDs { ast := "tag_rel" + strconv.Itoa(ti) b.Join("INNER", "tag_rel as "+ast, "question_id = "+ast+".object_id"). And(builder.Eq{ ast + ".status": entity.TagRelStatusAvailable, }). And(builder.In(ast+".tag_id", tagID)) args = append(args, entity.TagRelStatusAvailable) for _, t := range tagID { args = append(args, t) } } // check limit accepted if accepted { b.Where(builder.Eq{"adopted": schema.AnswerAcceptedEnable}) args = append(args, schema.AnswerAcceptedEnable) } // check question id if questionID != "" { b.Where(builder.Eq{"question_id": questionID}) args = append(args, questionID) } queryArgs := []interface{}{} countArgs := []interface{}{} countSQL, _, err := builder.MySQL().Select("count(*) total").From(b, "c").ToSQL() if err != nil { return } startNum := (page - 1) * pageSize querySQL, _, err := b.OrderBy(sr.parseOrder(ctx, order)).Limit(pageSize, startNum).ToSQL() if err != nil { return } queryArgs = append(queryArgs, querySQL) queryArgs = append(queryArgs, args...) countArgs = append(countArgs, countSQL) countArgs = append(countArgs, args...) res, err := sr.data.DB.Context(ctx).Query(queryArgs...) if err != nil { return } tr, err := sr.data.DB.Context(ctx).Query(countArgs...) if err != nil { return } total = converter.StringToInt64(string(tr[0]["total"])) resp, err = sr.parseResult(ctx, res, words) if err != nil { err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() } return } func (sr *searchRepo) parseOrder(ctx context.Context, order string) (res string) { switch order { case "newest": res = "created_at desc" case "active": res = "post_update_time desc" case "score": res = "vote_count desc" case "relevance": res = "relevance desc" default: res = "created_at desc" } return } // ParseSearchPluginResult parse search plugin result func (sr *searchRepo) ParseSearchPluginResult(ctx context.Context, sres []plugin.SearchResult, words []string) (resp []*schema.SearchResult, err error) { var ( qres []map[string][]byte res = make([]map[string][]byte, 0) b *builder.Builder ) for _, r := range sres { switch r.Type { case "question": b = builder.MySQL().Select(qFields...).From("question").Where(builder.Eq{"id": r.ID}). And(builder.Lt{"`status`": entity.QuestionStatusDeleted}) case "answer": b = builder.MySQL().Select(aFields...).From("answer").LeftJoin("`question`", "`question`.`id` = `answer`.`question_id`"). Where(builder.Eq{"`answer`.`id`": r.ID}). And(builder.Lt{"`question`.`status`": entity.QuestionStatusDeleted}). And(builder.Lt{"`answer`.`status`": entity.AnswerStatusDeleted}).And(builder.Eq{"`question`.`show`": entity.QuestionShow}) } qres, err = sr.data.DB.Context(ctx).Query(b) if err != nil || len(qres) == 0 { continue } res = append(res, qres[0]) } return sr.parseResult(ctx, res, words) } // parseResult parse search result, return the data structure func (sr *searchRepo) parseResult(ctx context.Context, res []map[string][]byte, words []string) (resp []*schema.SearchResult, err error) { questionIDs := make([]string, 0) userIDs := make([]string, 0) resultList := make([]*schema.SearchResult, 0) for _, r := range res { questionIDs = append(questionIDs, string(r["question_id"])) userIDs = append(userIDs, string(r["user_id"])) tp, _ := time.ParseInLocation("2006-01-02 15:04:05", string(r["created_at"]), time.Local) var ID = string(r["id"]) var QuestionID = string(r["question_id"]) if handler.GetEnableShortID(ctx) { ID = uid.EnShortID(ID) QuestionID = uid.EnShortID(QuestionID) } object := &schema.SearchObject{ ID: ID, QuestionID: QuestionID, Title: string(r["title"]), UrlTitle: htmltext.UrlTitle(string(r["title"])), Excerpt: htmltext.FetchMatchedExcerpt(string(r["parsed_text"]), words, "...", 100), CreatedAtParsed: tp.Unix(), UserInfo: &schema.SearchObjectUser{ ID: string(r["user_id"]), }, Tags: make([]*schema.TagResp, 0), VoteCount: converter.StringToInt(string(r["vote_count"])), Accepted: string(r["accepted"]) == "2", AnswerCount: converter.StringToInt(string(r["answer_count"])), } objectKey, err := obj.GetObjectTypeStrByObjectID(string(r["id"])) if err != nil { continue } switch objectKey { case "question": for k, v := range entity.AdminQuestionSearchStatus { if v == converter.StringToInt(string(r["status"])) { object.StatusStr = k break } } case "answer": for k, v := range entity.AdminAnswerSearchStatus { if v == converter.StringToInt(string(r["status"])) { object.StatusStr = k break } } } resultList = append(resultList, &schema.SearchResult{ ObjectType: objectKey, Object: object, }) } tagsMap, err := sr.tagCommon.BatchGetObjectTag(ctx, questionIDs) if err != nil { return nil, err } userInfoMap, err := sr.userCommon.BatchUserBasicInfoByID(ctx, userIDs) if err != nil { return nil, err } for _, item := range resultList { tags, ok := tagsMap[item.Object.QuestionID] if ok { item.Object.Tags = tags } if userInfo := userInfoMap[item.Object.UserInfo.ID]; userInfo != nil { item.Object.UserInfo.Username = userInfo.Username item.Object.UserInfo.DisplayName = userInfo.DisplayName item.Object.UserInfo.Rank = userInfo.Rank item.Object.UserInfo.Status = userInfo.Status } } return resultList, nil } func addRelevanceField(searchFields, words, fields []string) (res []string, args []interface{}) { relevanceRes := []string{} args = []interface{}{} for _, searchField := range searchFields { var ( relevance = "(LENGTH(" + searchField + ") - LENGTH(%s))" replacement = "REPLACE(%s, ?, '')" replaceField = searchField replaced string argsField = []interface{}{} ) res = fields for i, word := range words { if i == 0 { argsField = append(argsField, word) replaced = fmt.Sprintf(replacement, replaceField) } else { argsField = append(argsField, word) replaced = fmt.Sprintf(replacement, replaced) } } args = append(args, argsField...) relevance = fmt.Sprintf(relevance, replaced) relevanceRes = append(relevanceRes, relevance) } res = append(res, "("+strings.Join(relevanceRes, " + ")+") as relevance") return } func filterWords(words []string) (res []string) { for _, word := range words { if strings.TrimSpace(word) != "" { res = append(res, word) } } return }