Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 104 additions & 26 deletions backend/internal/data/repo/repo_entities.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ type (
ParentIDs []uuid.UUID `json:"parentIds"`
TagIDs []uuid.UUID `json:"tagIds"`
NegateTags bool `json:"negateTags"`
MatchAllTags bool `json:"matchAllTags"`
OnlyWithoutPhoto bool `json:"onlyWithoutPhoto"`
OnlyWithPhoto bool `json:"onlyWithPhoto"`
ParentItemIDs []uuid.UUID `json:"parentItemIds"`
Expand Down Expand Up @@ -225,6 +226,108 @@ type (

var mapEntitiesSummaryErr = mapTEachErrFunc(mapEntitySummary)

func (r *EntityRepository) tagFilterPredicates(ctx context.Context, gid uuid.UUID, q EntityQuery) []predicate.Entity {
descendantGroups := r.tagDescendantGroups(ctx, gid, q)
if q.MatchAllTags {
return []predicate.Entity{entityTagGroupsPredicate(descendantGroups, q.NegateTags)}
}

return []predicate.Entity{entityTagIDsPredicate(descendantGroups[0], q.NegateTags)}
}

func (r *EntityRepository) tagDescendantGroups(ctx context.Context, gid uuid.UUID, q EntityQuery) [][]uuid.UUID {
tagRepo := &TagRepository{r.db, r.bus}
ctxDescendants, span := entityTracer().Start(ctx, "repo.EntityRepository.QueryByGroup.tagDescendants",
trace.WithAttributes(attribute.Int("query.tag_ids.count", len(q.TagIDs))))
defer span.End()

var descendantGroups [][]uuid.UUID
if q.MatchAllTags {
descendantGroups = r.matchAllTagDescendantGroups(ctxDescendants, gid, tagRepo, q.TagIDs, span)
} else {
descendantGroups = [][]uuid.UUID{r.matchAnyTagDescendants(ctxDescendants, tagRepo, q.TagIDs, span)}
}

span.SetAttributes(attribute.Int("query.tag_descendants.count", tagDescendantCount(descendantGroups)))

return descendantGroups
}

func (r *EntityRepository) matchAllTagDescendantGroups(ctx context.Context, gid uuid.UUID, tagRepo *TagRepository, tagIDs []uuid.UUID, span trace.Span) [][]uuid.UUID {
descendantsByRoot, err := tagRepo.GetDescendantTagIDsByRoot(ctx, gid, tagIDs)
if err != nil {
recordSpanError(span, err)
log.Warn().Err(err).Msg("failed to get descendant tags, using only direct tag")
return directTagGroups(tagIDs)
}

descendantGroups := make([][]uuid.UUID, 0, len(tagIDs))
for _, tagID := range tagIDs {
descendants := descendantsByRoot[tagID]
if len(descendants) == 0 {
descendants = []uuid.UUID{tagID}
}
descendantGroups = append(descendantGroups, descendants)
}

return descendantGroups
}

func (r *EntityRepository) matchAnyTagDescendants(ctx context.Context, tagRepo *TagRepository, tagIDs []uuid.UUID, span trace.Span) []uuid.UUID {
descendants, err := tagRepo.GetDescendantTagIDs(ctx, tagIDs)
if err != nil {
recordSpanError(span, err)
log.Warn().Err(err).Msg("failed to get descendant tags, using only direct tags")
return tagIDs
}
if len(descendants) == 0 {
return tagIDs
}

return descendants
}

func directTagGroups(tagIDs []uuid.UUID) [][]uuid.UUID {
groups := make([][]uuid.UUID, 0, len(tagIDs))
for _, tagID := range tagIDs {
groups = append(groups, []uuid.UUID{tagID})
}

return groups
}

func tagDescendantCount(descendantGroups [][]uuid.UUID) int {
count := 0
for _, descendants := range descendantGroups {
count += len(descendants)
}

return count
}

func entityTagGroupsPredicate(descendantGroups [][]uuid.UUID, negate bool) predicate.Entity {
groupPredicates := make([]predicate.Entity, 0, len(descendantGroups))
for _, descendants := range descendantGroups {
groupPredicates = append(groupPredicates, entityTagIDsPredicate(descendants, false))
}
if negate {
return entity.Not(entity.And(groupPredicates...))
}

return entity.And(groupPredicates...)
}

func entityTagIDsPredicate(tagIDs []uuid.UUID, negate bool) predicate.Entity {
tagPredicates := lo.Map(tagIDs, func(l uuid.UUID, _ int) predicate.Entity {
return entity.HasTagWith(tag.ID(l))
})
if negate {
return entity.Not(entity.Or(tagPredicates...))
}

return entity.Or(tagPredicates...)
}

func mapEntitySummary(e *ent.Entity) EntitySummary {
var parent *EntitySummary
if e.Edges.Parent != nil {
Expand Down Expand Up @@ -602,32 +705,7 @@ func (r *EntityRepository) QueryByGroup(ctx context.Context, gid uuid.UUID, q En
var andPredicates []predicate.Entity
{
if len(q.TagIDs) > 0 {
tagRepo := &TagRepository{r.db, r.bus}
ctxDescendants, descSpan := entityTracer().Start(ctx, "repo.EntityRepository.QueryByGroup.tagDescendants",
trace.WithAttributes(attribute.Int("query.tag_ids.count", len(q.TagIDs))))
descendants, err := tagRepo.GetDescendantTagIDs(ctxDescendants, q.TagIDs)
if err != nil {
recordSpanError(descSpan, err)
log.Warn().Err(err).Msg("failed to get descendant tags, using only direct tags")
descendants = q.TagIDs
} else if len(descendants) == 0 {
descendants = q.TagIDs
}
descSpan.SetAttributes(attribute.Int("query.tag_descendants.count", len(descendants)))
descSpan.End()

var tagPredicates []predicate.Entity
if !q.NegateTags {
tagPredicates = lo.Map(descendants, func(l uuid.UUID, _ int) predicate.Entity {
return entity.HasTagWith(tag.ID(l))
})
andPredicates = append(andPredicates, entity.Or(tagPredicates...))
} else {
tagPredicates = lo.Map(descendants, func(l uuid.UUID, _ int) predicate.Entity {
return entity.Not(entity.HasTagWith(tag.ID(l)))
})
andPredicates = append(andPredicates, entity.And(tagPredicates...))
}
andPredicates = append(andPredicates, r.tagFilterPredicates(ctx, gid, q)...)
}

if q.OnlyWithoutPhoto {
Expand Down
117 changes: 117 additions & 0 deletions backend/internal/data/repo/repo_entities_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,123 @@ func TestEntityRepository_Update_Tags(t *testing.T) {
}
}

func TestEntityRepository_QueryByGroup_MatchAllTags(t *testing.T) {
containerET := useContainerEntityType(t)
itemET := useItemEntityType(t)
tags := useTags(t, 2)
childTag1, err := tRepos.Tags.Create(context.Background(), tGroup.ID, TagCreate{
Name: fk.Str(10),
Description: fk.Str(100),
ParentID: tags[0].ID,
})
require.NoError(t, err)
childTag2, err := tRepos.Tags.Create(context.Background(), tGroup.ID, TagCreate{
Name: fk.Str(10),
Description: fk.Str(100),
ParentID: tags[1].ID,
})
require.NoError(t, err)
t.Cleanup(func() {
_ = tRepos.Tags.delete(context.Background(), childTag2.ID)
_ = tRepos.Tags.delete(context.Background(), childTag1.ID)
})

container, err := tRepos.Entities.Create(context.Background(), tGroup.ID, EntityCreate{
Name: fk.Str(10),
Description: fk.Str(100),
EntityTypeID: containerET.ID,
})
require.NoError(t, err)

wideMatch, err := tRepos.Entities.Create(context.Background(), tGroup.ID, EntityCreate{
Name: fk.Str(10),
Description: fk.Str(100),
ParentID: container.ID,
EntityTypeID: itemET.ID,
TagIDs: []uuid.UUID{tags[0].ID, tags[1].ID},
})
require.NoError(t, err)

narrowMatch, err := tRepos.Entities.Create(context.Background(), tGroup.ID, EntityCreate{
Name: fk.Str(10),
Description: fk.Str(100),
ParentID: container.ID,
EntityTypeID: itemET.ID,
TagIDs: []uuid.UUID{tags[0].ID},
})
require.NoError(t, err)

descendantWideMatch, err := tRepos.Entities.Create(context.Background(), tGroup.ID, EntityCreate{
Name: fk.Str(10),
Description: fk.Str(100),
ParentID: container.ID,
EntityTypeID: itemET.ID,
TagIDs: []uuid.UUID{childTag1.ID, childTag2.ID},
})
require.NoError(t, err)

descendantNarrowMatch, err := tRepos.Entities.Create(context.Background(), tGroup.ID, EntityCreate{
Name: fk.Str(10),
Description: fk.Str(100),
ParentID: container.ID,
EntityTypeID: itemET.ID,
TagIDs: []uuid.UUID{childTag1.ID},
})
require.NoError(t, err)

t.Cleanup(func() {
_ = tRepos.Entities.Delete(context.Background(), descendantNarrowMatch.ID)
_ = tRepos.Entities.Delete(context.Background(), descendantWideMatch.ID)
_ = tRepos.Entities.Delete(context.Background(), narrowMatch.ID)
_ = tRepos.Entities.Delete(context.Background(), wideMatch.ID)
_ = tRepos.Entities.Delete(context.Background(), container.ID)
})

query := EntityQuery{
Page: -1,
PageSize: -1,
TagIDs: []uuid.UUID{tags[0].ID, tags[1].ID},
}

results, err := tRepos.Entities.QueryByGroup(context.Background(), tGroup.ID, query)
require.NoError(t, err)

ids := make([]uuid.UUID, 0, len(results.Items))
for _, item := range results.Items {
ids = append(ids, item.ID)
}
assert.Contains(t, ids, wideMatch.ID)
assert.Contains(t, ids, narrowMatch.ID)
assert.Contains(t, ids, descendantWideMatch.ID)
assert.Contains(t, ids, descendantNarrowMatch.ID)

query.MatchAllTags = true
results, err = tRepos.Entities.QueryByGroup(context.Background(), tGroup.ID, query)
require.NoError(t, err)

ids = ids[:0]
for _, item := range results.Items {
ids = append(ids, item.ID)
}
assert.Contains(t, ids, wideMatch.ID)
assert.Contains(t, ids, descendantWideMatch.ID)
assert.NotContains(t, ids, narrowMatch.ID)
assert.NotContains(t, ids, descendantNarrowMatch.ID)

query.NegateTags = true
results, err = tRepos.Entities.QueryByGroup(context.Background(), tGroup.ID, query)
require.NoError(t, err)

ids = ids[:0]
for _, item := range results.Items {
ids = append(ids, item.ID)
}
assert.Contains(t, ids, narrowMatch.ID)
assert.Contains(t, ids, descendantNarrowMatch.ID)
assert.NotContains(t, ids, wideMatch.ID)
assert.NotContains(t, ids, descendantWideMatch.ID)
}

func TestEntityRepository_Update(t *testing.T) {
entities := useEntities(t, 3)

Expand Down
54 changes: 54 additions & 0 deletions backend/internal/data/repo/repo_tags.go
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,60 @@ func (r *TagRepository) GetDescendantTagIDs(ctx context.Context, tagIDs []uuid.U
return descendantIDs, nil
}

// GetDescendantTagIDsByRoot retrieves descendant tag IDs for each provided root tag ID.
// It batches the database read so callers can avoid repeated descendant lookups per tag.
func (r *TagRepository) GetDescendantTagIDsByRoot(ctx context.Context, gid uuid.UUID, tagIDs []uuid.UUID) (map[uuid.UUID][]uuid.UUID, error) {
if len(tagIDs) == 0 {
return map[uuid.UUID][]uuid.UUID{}, nil
}

tags, err := r.db.Tag.Query().
Where(tag.HasGroupWith(group.ID(gid))).
WithParent().
All(ctx)
if err != nil {
return nil, err
}

childrenByParent := make(map[uuid.UUID][]uuid.UUID, len(tags))
for _, tg := range tags {
if tg.Edges.Parent != nil {
childrenByParent[tg.Edges.Parent.ID] = append(childrenByParent[tg.Edges.Parent.ID], tg.ID)
}
}

descendantsByRoot := make(map[uuid.UUID][]uuid.UUID, len(tagIDs))
for _, rootID := range tagIDs {
descendantsByRoot[rootID] = descendantsFromChildren(rootID, childrenByParent)
}

return descendantsByRoot, nil
}

func descendantsFromChildren(rootID uuid.UUID, childrenByParent map[uuid.UUID][]uuid.UUID) []uuid.UUID {
result := make(map[uuid.UUID]bool)
queue := []uuid.UUID{rootID}

for len(queue) > 0 {
currentID := queue[0]
queue = queue[1:]

if result[currentID] {
continue
}
result[currentID] = true

queue = append(queue, childrenByParent[currentID]...)
}

descendantIDs := make([]uuid.UUID, 0, len(result))
for id := range result {
descendantIDs = append(descendantIDs, id)
}

return descendantIDs
}

// getSubtreeDepth calculates the maximum depth of the subtree rooted at the given tag ID.
// Uses a recursive CTE to traverse the entire subtree and find the deepest level.
// Returns 1 for a tag with no children, and increases by 1 for each level.
Expand Down
3 changes: 2 additions & 1 deletion frontend/lib/api/classes/items.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export type ItemsQuery = {
parentIds?: string[];
tags?: string[];
negateTags?: boolean;
matchAllTags?: boolean;
onlyWithoutPhoto?: boolean;
onlyWithPhoto?: boolean;
q?: string;
Expand Down Expand Up @@ -203,7 +204,7 @@ export class ItemsApi extends BaseAPI {
return {
...resp,
data: resp.data?.items ?? [],
} as { data: EntitySummary[]; error: any; status: number };
} as { data: EntitySummary[]; error: unknown; status: number };
}

getTree(tq: TreeQuery = { withItems: false }) {
Expand Down
1 change: 1 addition & 0 deletions frontend/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,7 @@
"model_number": "Model Number",
"name": "Name",
"negate_tags": "Negate Selected Tags",
"match_all_tags": "Match All Selected Tags",
"next_page": "Next Page",
"no_attachments": "No attachments found",
"no_results": "No Items Found",
Expand Down
Loading
Loading