236 lines
7.4 KiB
Go
236 lines
7.4 KiB
Go
package store
|
|
|
|
import (
|
|
"strings"
|
|
|
|
"github.com/huandu/go-sqlbuilder"
|
|
)
|
|
|
|
// sanitizeFTS5QueryForParam prepares a query for use as a parameter (without quotes)
|
|
func sanitizeFTS5QueryForParam(query string) string {
|
|
if query == "" {
|
|
return ""
|
|
}
|
|
// For parameterized queries, escape quotes but don't wrap in quotes
|
|
// FTS5 will handle the query format when used as a parameter
|
|
return strings.ReplaceAll(query, `"`, `""`)
|
|
}
|
|
|
|
// searchBuilder builds SQL queries for link searching using go-sqlbuilder.
|
|
// This pattern ensures reliable argument ordering and makes it easy to add
|
|
// new filter types without fragile manual argument manipulation.
|
|
type searchBuilder struct {
|
|
// For non-FTS queries, we use a simple SelectBuilder
|
|
sb *sqlbuilder.SelectBuilder
|
|
|
|
// For FTS queries, we need CTE support
|
|
needsFTS bool
|
|
ftsQuery string
|
|
ftsUserID string
|
|
likePattern string
|
|
|
|
// Track filters to apply
|
|
userID string
|
|
categoryID string
|
|
labelIDs []string
|
|
}
|
|
|
|
// newSearchBuilder creates a new searchBuilder instance
|
|
func newSearchBuilder() *searchBuilder {
|
|
return &searchBuilder{
|
|
sb: sqlbuilder.NewSelectBuilder(),
|
|
}
|
|
}
|
|
|
|
// addUserFilter adds a filter for the user ID (required)
|
|
func (b *searchBuilder) addUserFilter(userID string) {
|
|
b.userID = userID
|
|
}
|
|
|
|
// addCategoryFilter adds a filter for category ID (optional)
|
|
func (b *searchBuilder) addCategoryFilter(categoryID string) {
|
|
b.categoryID = categoryID
|
|
}
|
|
|
|
// addSearchFilter adds full-text search across URL, title, and content
|
|
func (b *searchBuilder) addSearchFilter(query, userID string) {
|
|
if query == "" {
|
|
return
|
|
}
|
|
b.needsFTS = true
|
|
b.likePattern = "%" + query + "%"
|
|
// Store raw query for parameterized FTS5 query
|
|
b.ftsQuery = query
|
|
b.ftsUserID = userID
|
|
}
|
|
|
|
// addLabelsFilter adds a filter for label IDs (optional, for future use)
|
|
func (b *searchBuilder) addLabelsFilter(labelIDs []string) {
|
|
b.labelIDs = labelIDs
|
|
}
|
|
|
|
// buildSelectQuery builds a complete SELECT query for listing links
|
|
func (b *searchBuilder) buildSelectQuery(limit, offset int) (string, []any) {
|
|
if b.needsFTS {
|
|
return b.buildFTSSelectQuery(limit, offset)
|
|
}
|
|
return b.buildSimpleSelectQuery(limit, offset)
|
|
}
|
|
|
|
// buildCountQuery builds a complete SELECT COUNT query
|
|
func (b *searchBuilder) buildCountQuery() (string, []any) {
|
|
if b.needsFTS {
|
|
return b.buildFTSCountQuery()
|
|
}
|
|
return b.buildSimpleCountQuery()
|
|
}
|
|
|
|
// buildSimpleSelectQuery builds a SELECT query without FTS
|
|
func (b *searchBuilder) buildSimpleSelectQuery(limit, offset int) (string, []any) {
|
|
sb := sqlbuilder.NewSelectBuilder()
|
|
sb.Select("l.id", "l.url", "l.user_id", "l.total_size", "l.created_at", "l.updated_at")
|
|
sb.From("links l")
|
|
|
|
b.applyFilters(sb)
|
|
|
|
sb.OrderBy("l.created_at DESC")
|
|
sb.Limit(limit)
|
|
sb.Offset(offset)
|
|
|
|
query, args := sb.Build()
|
|
return query, toAnySlice(args)
|
|
}
|
|
|
|
// buildSimpleCountQuery builds a COUNT query without FTS
|
|
func (b *searchBuilder) buildSimpleCountQuery() (string, []any) {
|
|
sb := sqlbuilder.NewSelectBuilder()
|
|
sb.Select("COUNT(DISTINCT l.id)")
|
|
sb.From("links l")
|
|
|
|
b.applyFilters(sb)
|
|
|
|
query, args := sb.Build()
|
|
return query, toAnySlice(args)
|
|
}
|
|
|
|
// buildFTSSelectQuery builds a SELECT query with FTS using CTE
|
|
func (b *searchBuilder) buildFTSSelectQuery(limit, offset int) (string, []any) {
|
|
// Build FTS CTE subquery
|
|
ftsSb := sqlbuilder.NewSelectBuilder()
|
|
ftsSb.Distinct()
|
|
ftsSb.Select("a.link_id")
|
|
ftsSb.From("archive_files_fts fts")
|
|
ftsSb.JoinWithOption(sqlbuilder.InnerJoin, "archive_files af", "fts.id = af.id")
|
|
ftsSb.JoinWithOption(sqlbuilder.InnerJoin, "archives a", "af.archive_id = a.id")
|
|
// Use parameterized query for FTS5 MATCH
|
|
// FTS5 supports parameterized queries, and we sanitize the query for safety
|
|
ftsQueryParam := sanitizeFTS5QueryForParam(b.ftsQuery)
|
|
ftsSb.Where("fts.content MATCH " + ftsSb.Var(ftsQueryParam))
|
|
ftsSb.Where(ftsSb.Equal("a.user_id", b.ftsUserID))
|
|
|
|
// Create CTE
|
|
cteQuery := sqlbuilder.CTEQuery("fts_matches").As(ftsSb)
|
|
|
|
// Build main SELECT using the CTE
|
|
mainSb := sqlbuilder.With(cteQuery).Select(
|
|
"DISTINCT l.id", "l.url", "l.user_id", "l.total_size", "l.created_at", "l.updated_at",
|
|
)
|
|
mainSb.From("links l")
|
|
|
|
// Add joins for search
|
|
mainSb.JoinWithOption(sqlbuilder.LeftJoin, "archives a_title", "l.id = a_title.link_id")
|
|
mainSb.JoinWithOption(sqlbuilder.LeftJoin, "fts_matches fm", "l.id = fm.link_id")
|
|
|
|
// Apply standard filters
|
|
b.applyFilters(mainSb)
|
|
|
|
// Add search conditions (URL, title, or FTS match)
|
|
urlCond := mainSb.Like("l.url", b.likePattern)
|
|
titleCond := mainSb.And(mainSb.IsNotNull("a_title.title"), mainSb.Like("a_title.title", b.likePattern))
|
|
ftsCond := mainSb.IsNotNull("fm.link_id")
|
|
mainSb.Where(mainSb.Or(urlCond, titleCond, ftsCond))
|
|
|
|
mainSb.OrderBy("l.created_at DESC")
|
|
mainSb.Limit(limit)
|
|
mainSb.Offset(offset)
|
|
|
|
// Build main query - sqlbuilder.With should handle CTE args automatically
|
|
query, args := mainSb.Build()
|
|
return query, toAnySlice(args)
|
|
}
|
|
|
|
// buildFTSCountQuery builds a COUNT query with FTS using CTE
|
|
func (b *searchBuilder) buildFTSCountQuery() (string, []any) {
|
|
// Build FTS CTE subquery
|
|
ftsSb := sqlbuilder.NewSelectBuilder()
|
|
ftsSb.Distinct()
|
|
ftsSb.Select("a.link_id")
|
|
ftsSb.From("archive_files_fts fts")
|
|
ftsSb.JoinWithOption(sqlbuilder.InnerJoin, "archive_files af", "fts.id = af.id")
|
|
ftsSb.JoinWithOption(sqlbuilder.InnerJoin, "archives a", "af.archive_id = a.id")
|
|
// Use parameterized query for FTS5 MATCH
|
|
// FTS5 supports parameterized queries, and we sanitize the query for safety
|
|
ftsQueryParam := sanitizeFTS5QueryForParam(b.ftsQuery)
|
|
ftsSb.Where("fts.content MATCH " + ftsSb.Var(ftsQueryParam))
|
|
ftsSb.Where(ftsSb.Equal("a.user_id", b.ftsUserID))
|
|
|
|
// Create CTE
|
|
cteQuery := sqlbuilder.CTEQuery("fts_matches").As(ftsSb)
|
|
|
|
// Build main SELECT using the CTE
|
|
mainSb := sqlbuilder.With(cteQuery).Select("COUNT(DISTINCT l.id)")
|
|
mainSb.From("links l")
|
|
|
|
// Add joins for search
|
|
mainSb.JoinWithOption(sqlbuilder.LeftJoin, "archives a_title", "l.id = a_title.link_id")
|
|
mainSb.JoinWithOption(sqlbuilder.LeftJoin, "fts_matches fm", "l.id = fm.link_id")
|
|
|
|
// Apply standard filters
|
|
b.applyFilters(mainSb)
|
|
|
|
// Add search conditions
|
|
urlCond := mainSb.Like("l.url", b.likePattern)
|
|
titleCond := mainSb.And(mainSb.IsNotNull("a_title.title"), mainSb.Like("a_title.title", b.likePattern))
|
|
ftsCond := mainSb.IsNotNull("fm.link_id")
|
|
mainSb.Where(mainSb.Or(urlCond, titleCond, ftsCond))
|
|
|
|
// Build main query - sqlbuilder.With should handle CTE args automatically
|
|
query, args := mainSb.Build()
|
|
return query, toAnySlice(args)
|
|
}
|
|
|
|
// applyFilters applies common filters (user, category, labels) to a SelectBuilder
|
|
func (b *searchBuilder) applyFilters(sb *sqlbuilder.SelectBuilder) {
|
|
// User filter (required)
|
|
if b.userID != "" {
|
|
sb.Where(sb.Equal("l.user_id", b.userID))
|
|
}
|
|
|
|
// Category filter (optional)
|
|
if b.categoryID != "" {
|
|
sb.JoinWithOption(sqlbuilder.InnerJoin, "link_categories lc", "l.id = lc.link_id")
|
|
sb.Where(sb.Equal("lc.category_id", b.categoryID))
|
|
}
|
|
|
|
// Labels filter (optional, for future use)
|
|
if len(b.labelIDs) > 0 {
|
|
sb.JoinWithOption(sqlbuilder.InnerJoin, "link_labels ll", "l.id = ll.link_id")
|
|
sb.Where(sb.In("ll.label_id", toInterfaceSlice(b.labelIDs)...))
|
|
}
|
|
}
|
|
|
|
// toAnySlice converts []interface{} to []any
|
|
func toAnySlice(args []interface{}) []any {
|
|
result := make([]any, len(args))
|
|
copy(result, args)
|
|
return result
|
|
}
|
|
|
|
// toInterfaceSlice converts []string to []interface{} for sqlbuilder
|
|
func toInterfaceSlice(s []string) []interface{} {
|
|
result := make([]interface{}, len(s))
|
|
for i, v := range s {
|
|
result[i] = v
|
|
}
|
|
return result
|
|
}
|