Files
mgdigital f2dcbf98fc Reduce aggregations in web UI (#87)
Fix https://github.com/bitmagnet-io/bitmagnet/issues/85

- Accurate counts in the web UI will only be shown for the top-level filters; when deep-filtering, the counts will show a less than or equal sign: `≤`, indicating the number is a "maximum possible" rather than an accurate count; I'm still not sure about the UX aspect of showing the `≤` which could be confusing if you're not sure what it means, but I figured it's better than showing nothing at all - open to suggestions!
- A background process keeps the in-memory query cache warm for the top-level aggregations (by default every 10 minutes), allowing them to be served instantly
- The query cache TTL has been increased to 20 minutes; improved performance is preferable to seeing the most up-to-date information
- Pagination has been refactored to account for not knowing the total number of pages; internally the search engine will request 1 more item than it needs to know if there's a next page to advance to
- Some general refactoring of the web app
2023-12-21 16:08:38 +00:00

530 lines
12 KiB
Go

package query
import (
"context"
"errors"
"fmt"
"github.com/bitmagnet-io/bitmagnet/internal/database/dao"
"github.com/bitmagnet-io/bitmagnet/internal/maps"
"github.com/bitmagnet-io/bitmagnet/internal/model"
"gorm.io/gen"
"gorm.io/gen/field"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"strings"
"sync"
)
type ResultItem struct {
QueryStringRank float64
}
type GenericResult[T interface{}] struct {
TotalCount uint
HasNextPage bool
Items []T
Aggregations Aggregations
}
type SubQueryFactory = func(context.Context, *dao.Query) SubQuery
func GenericQuery[T interface{}](
ctx context.Context,
daoQ *dao.Query,
option Option,
tableName string,
factory SubQueryFactory,
) (r GenericResult[T], _ error) {
dbCtx := dbContext{
q: daoQ,
tableName: tableName,
factory: factory,
}
builder, optionErr := option(newQueryContext(dbCtx))
ctx = builder.createContext(ctx)
if optionErr != nil {
return r, optionErr
}
newSubQuery := func() (SubQuery, error) {
sq := factory(ctx, daoQ)
if selectErr := builder.applySelect(sq); selectErr != nil {
return sq, selectErr
}
if preErr := builder.applyPre(sq); preErr != nil {
return sq, preErr
}
return sq, nil
}
var errs []error
mtx := sync.Mutex{}
addErr := func(err error) {
mtx.Lock()
defer mtx.Unlock()
errs = append(errs, err)
}
wg := sync.WaitGroup{}
wg.Add(3)
go (func() {
defer wg.Done()
if builder.withTotalCount() {
sq, sqErr := newSubQuery()
if sqErr != nil {
addErr(sqErr)
return
}
if tc, countErr := sq.Count(); countErr != nil {
addErr(countErr)
} else {
r.TotalCount = uint(tc)
}
}
})()
go (func() {
defer wg.Done()
sq, sqErr := newSubQuery()
if sqErr != nil {
addErr(sqErr)
return
}
if postErr := builder.applyPost(sq); postErr != nil {
addErr(postErr)
return
}
if !builder.hasZeroLimit() || builder.needsNextPage() {
var items []T
if txErr := sq.UnderlyingDB().Find(&items).Error; txErr != nil {
addErr(txErr)
return
}
// copy items slice to avoid modifying cached results
copiedItems := append([]T{}, items...)
if builder.hasNextPage(len(copiedItems)) {
r.HasNextPage = true
copiedItems = copiedItems[:len(copiedItems)-1]
}
if len(copiedItems) > 0 {
if cbErr := builder.applyCallbacks(ctx, copiedItems); cbErr != nil {
addErr(cbErr)
return
}
}
r.Items = copiedItems
}
})()
go (func() {
defer wg.Done()
if aggs, aggErr := builder.calculateAggregations(ctx); aggErr != nil {
addErr(aggErr)
} else {
r.Aggregations = aggs
}
})()
wg.Wait()
return r, errors.Join(errs...)
}
type BaseSubQuery interface {
Count() (int64, error)
TableName() string
UnderlyingDB() *gorm.DB
}
type SubQuery interface {
BaseSubQuery
Scopes(...GormScope) SubQuery
}
type GenericSubQuery[T BaseSubQuery] struct {
SubQuery BaseSubQuery
}
func (sq GenericSubQuery[T]) Count() (int64, error) {
return sq.SubQuery.Count()
}
func (sq GenericSubQuery[T]) TableName() string {
return sq.SubQuery.TableName()
}
func (sq GenericSubQuery[T]) UnderlyingDB() *gorm.DB {
return sq.SubQuery.UnderlyingDB()
}
type scoper[T BaseSubQuery] interface {
Scopes(funcs ...GormScope) T
}
func (sq GenericSubQuery[T]) Scopes(fns ...func(gen.Dao) gen.Dao) SubQuery {
sq.SubQuery = sq.SubQuery.(scoper[T]).Scopes(fns...)
return sq
}
type Scope = func(SubQuery) error
type GormScope = func(gen.Dao) gen.Dao
type DbContext interface {
Query() *dao.Query
TableName() string
NewSubQuery(context.Context) SubQuery
}
type dbContext struct {
q *dao.Query
tableName string
factory SubQueryFactory
}
func (db dbContext) Query() *dao.Query {
return db.q
}
func (db dbContext) TableName() string {
return db.tableName
}
func (db dbContext) NewSubQuery(ctx context.Context) SubQuery {
return db.factory(ctx, db.q)
}
type CallbackContext interface {
DbContext
Lock()
Unlock()
}
type callbackContext struct {
dbContext
*sync.Mutex
}
type Callback func(ctx context.Context, cbCtx CallbackContext, results any) error
type OptionBuilder interface {
DbContext
Table(string) OptionBuilder
Join(...TableJoin) OptionBuilder
RequireJoin(...string) OptionBuilder
Scope(...Scope) OptionBuilder
Select(...clause.Expr) OptionBuilder
OrderBy(...clause.OrderByColumn) OptionBuilder
Limit(uint) OptionBuilder
Offset(uint) OptionBuilder
Group(...clause.Column) OptionBuilder
Facet(...Facet) OptionBuilder
Preload(...field.RelationField) OptionBuilder
Callback(...Callback) OptionBuilder
Context(func(ctx context.Context) context.Context) OptionBuilder
applySelect(SubQuery) error
applyPre(SubQuery) error
applyPost(SubQuery) error
createFacetsFilterCriteria() (Criteria, error)
calculateAggregations(context.Context) (Aggregations, error)
WithTotalCount(bool) OptionBuilder
WithHasNextPage(bool) OptionBuilder
withTotalCount() bool
applyCallbacks(context.Context, any) error
hasZeroLimit() bool
needsNextPage() bool
hasNextPage(nItems int) bool
withCurrentFacet(string) OptionBuilder
createContext(context.Context) context.Context
}
type optionBuilder struct {
dbContext
joins map[string]TableJoin
requiredJoins maps.InsertMap[string, struct{}]
scopes []Scope
selections []clause.Expr
groupBy []clause.Column
orderBy []clause.OrderByColumn
limit model.NullUint
nextPage bool
offset uint
facets []Facet
currentFacet string
preloads []field.RelationField
totalCount bool
callbacks []Callback
contextFn func(context.Context) context.Context
}
type RawJoin struct {
Query string
Args []interface{}
}
func newQueryContext(dbCtx dbContext) OptionBuilder {
return optionBuilder{
dbContext: dbCtx,
joins: make(map[string]TableJoin),
requiredJoins: maps.NewInsertMap[string, struct{}](),
}
}
func (b optionBuilder) Table(name string) OptionBuilder {
b.dbContext.tableName = name
return b.Scope(func(sq SubQuery) error {
sq.UnderlyingDB().Table(name)
return nil
})
}
func (b optionBuilder) Join(joins ...TableJoin) OptionBuilder {
bJoins := make(map[string]TableJoin, len(b.joins))
for _, j := range b.joins {
bJoins[j.Table.TableName()] = j
}
bRequiredJoins := b.requiredJoins.Copy()
for _, j := range joins {
bJoins[j.Table.TableName()] = j
if j.Required {
bRequiredJoins.SetKey(j.Table.TableName())
}
}
b.joins = bJoins
b.requiredJoins = bRequiredJoins
return b
}
func (b optionBuilder) RequireJoin(names ...string) OptionBuilder {
bRequiredJoins := b.requiredJoins.Copy()
for _, name := range names {
bRequiredJoins.SetKey(name)
}
b.requiredJoins = bRequiredJoins
return b
}
func (b optionBuilder) Scope(scopes ...Scope) OptionBuilder {
b.scopes = append(b.scopes, scopes...)
return b
}
func (b optionBuilder) Select(selections ...clause.Expr) OptionBuilder {
b.selections = append(b.selections, selections...)
return b
}
func (b optionBuilder) Group(columns ...clause.Column) OptionBuilder {
b.groupBy = append(b.groupBy, columns...)
return b
}
func (b optionBuilder) OrderBy(columns ...clause.OrderByColumn) OptionBuilder {
b.orderBy = append(b.orderBy, columns...)
return b
}
func (b optionBuilder) Limit(limit uint) OptionBuilder {
b.limit = model.NewNullUint(limit)
return b
}
func (b optionBuilder) Offset(offset uint) OptionBuilder {
b.offset = offset
return b
}
func (b optionBuilder) Facet(facets ...Facet) OptionBuilder {
b.facets = append(b.facets, facets...)
return b
}
func (b optionBuilder) Preload(relations ...field.RelationField) OptionBuilder {
b.preloads = append(b.preloads, relations...)
return b
}
func (b optionBuilder) Callback(callbacks ...Callback) OptionBuilder {
b.callbacks = append(b.callbacks, callbacks...)
return b
}
func (b optionBuilder) Context(fn func(context.Context) context.Context) OptionBuilder {
prevFn := b.contextFn
b.contextFn = func(ctx context.Context) context.Context {
if prevFn != nil {
ctx = prevFn(ctx)
}
return fn(ctx)
}
return b
}
func (b optionBuilder) WithTotalCount(bl bool) OptionBuilder {
b.totalCount = bl
return b
}
func (b optionBuilder) WithHasNextPage(bl bool) OptionBuilder {
b.nextPage = bl
return b
}
func (b optionBuilder) withTotalCount() bool {
return b.totalCount
}
func (b optionBuilder) hasZeroLimit() bool {
return b.limit.Valid && b.limit.Uint == 0
}
func (b optionBuilder) needsNextPage() bool {
return b.limit.Valid && b.nextPage
}
func (b optionBuilder) hasNextPage(nItems int) bool {
if !b.nextPage {
return false
}
if !b.limit.Valid {
return false
}
return nItems > int(b.limit.Uint)
}
func (b optionBuilder) withCurrentFacet(facet string) OptionBuilder {
b.currentFacet = facet
return b
}
func (b optionBuilder) createContext(ctx context.Context) context.Context {
if b.contextFn != nil {
return b.contextFn(ctx)
}
return ctx
}
func (b optionBuilder) applySelect(sq SubQuery) error {
var selectQueryParts []string
selectQueryArgs := make([]interface{}, 0)
if len(b.selections) == 0 {
selectQueryParts = append(selectQueryParts, "*")
} else {
for _, s := range b.selections {
selectQueryParts = append(selectQueryParts, s.SQL)
selectQueryArgs = append(selectQueryArgs, s.Vars...)
}
}
sq.UnderlyingDB().Select(strings.Join(selectQueryParts, ", "), selectQueryArgs...)
return nil
}
func (b optionBuilder) applyPre(sq SubQuery) error {
for _, s := range b.scopes {
if err := s(sq); err != nil {
return err
}
}
requiredJoins := b.requiredJoins.Copy()
aggC, aggCErr := b.createFacetsFilterCriteria()
if aggCErr != nil {
return aggCErr
}
rawAggC, rawAggCErr := aggC.Raw(b)
if rawAggCErr != nil {
return rawAggCErr
}
requiredJoins.SetEntries(rawAggC.Joins.Entries()...)
joins, joinsErr := extractRequiredJoins(b.dbContext.tableName, b.joins, requiredJoins)
if joinsErr != nil {
return joinsErr
}
applyJoins(sq, joins...)
sq.UnderlyingDB().Where(rawAggC.Query, rawAggC.Args...)
if len(b.groupBy) > 0 {
sq.UnderlyingDB().Clauses(clause.GroupBy{
Columns: b.groupBy,
})
}
return nil
}
func extractRequiredJoins(tableName string, joins map[string]TableJoin, requiredJoins maps.InsertMap[string, struct{}]) ([]TableJoin, error) {
resolvedJoins := maps.NewInsertMap[string, TableJoin]()
var addJoin func(name string) error
addJoin = func(name string) error {
if name == tableName {
return nil
}
j, ok := joins[name]
if !ok {
return fmt.Errorf("required join not found: %s", name)
}
for _, depName := range j.Dependencies.Keys() {
if err := addJoin(depName); err != nil {
return err
}
}
resolvedJoins.Set(j.Table.TableName(), j)
return nil
}
for _, joinName := range requiredJoins.Keys() {
if err := addJoin(joinName); err != nil {
return nil, err
}
}
return resolvedJoins.Values(), nil
}
func applyJoins(sq SubQuery, joins ...TableJoin) {
for _, j := range joins {
join := j
sq.Scopes(func(dao gen.Dao) gen.Dao {
switch join.Type {
case TableJoinTypeInner:
return dao.Join(join.Table, join.On...)
case TableJoinTypeLeft:
return dao.LeftJoin(join.Table, join.On...)
case TableJoinTypeRight:
return dao.RightJoin(join.Table, join.On...)
}
panic("invalid join type")
})
}
}
func (b optionBuilder) applyPost(sq SubQuery) error {
if len(b.orderBy) > 0 {
sq.UnderlyingDB().Statement.AddClause(clause.OrderBy{
Columns: b.orderBy,
})
}
if b.limit.Valid {
limit := int(b.limit.Uint)
if b.nextPage {
limit++
}
sq.UnderlyingDB().Limit(limit)
}
sq.UnderlyingDB().Offset(int(b.offset))
for _, p := range b.preloads {
sq.UnderlyingDB().Preload(p.Name(), p)
}
return nil
}
func (b optionBuilder) applyCallbacks(ctx context.Context, results any) error {
cbCtx := callbackContext{
dbContext: b.dbContext,
Mutex: &sync.Mutex{},
}
var errs []error
wg := sync.WaitGroup{}
wg.Add(len(b.callbacks))
for _, cb := range b.callbacks {
go (func(cb Callback) {
defer wg.Done()
if err := cb(ctx, cbCtx, results); err != nil {
cbCtx.Lock()
defer cbCtx.Unlock()
errs = append(errs, err)
}
})(cb)
}
wg.Wait()
return errors.Join(errs...)
}