Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
71 changes: 54 additions & 17 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 @@ -605,28 +606,64 @@ func (r *EntityRepository) QueryByGroup(ctx context.Context, gid uuid.UUID, q En
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
descendantGroups := make([][]uuid.UUID, 0, len(q.TagIDs))
descendantCount := 0
if q.MatchAllTags {
descendantsByRoot, err := tagRepo.GetDescendantTagIDsByRoot(ctxDescendants, q.TagIDs)
if err != nil {
recordSpanError(descSpan, err)
log.Warn().Err(err).Msg("failed to get descendant tags, using only direct tag")
for _, tagID := range q.TagIDs {
descendantGroups = append(descendantGroups, []uuid.UUID{tagID})
}
descendantCount = len(q.TagIDs)
} else {
for _, tagID := range q.TagIDs {
descendants := descendantsByRoot[tagID]
if len(descendants) == 0 {
descendants = []uuid.UUID{tagID}
}
descendantGroups = append(descendantGroups, descendants)
descendantCount += len(descendants)
}
}
} else {
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
}
descendantGroups = append(descendantGroups, descendants)
descendantCount = len(descendants)
}
descSpan.SetAttributes(attribute.Int("query.tag_descendants.count", len(descendants)))
descSpan.SetAttributes(attribute.Int("query.tag_descendants.count", descendantCount))
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...))
if q.MatchAllTags {
groupPredicates := make([]predicate.Entity, 0, len(descendantGroups))
for _, descendants := range descendantGroups {
tagPredicates := lo.Map(descendants, func(l uuid.UUID, _ int) predicate.Entity {
return entity.HasTagWith(tag.ID(l))
})
groupPredicates = append(groupPredicates, entity.Or(tagPredicates...))
}
if q.NegateTags {
andPredicates = append(andPredicates, entity.Not(entity.And(groupPredicates...)))
} else {
andPredicates = append(andPredicates, entity.And(groupPredicates...))
}
} else {
tagPredicates = lo.Map(descendants, func(l uuid.UUID, _ int) predicate.Entity {
return entity.Not(entity.HasTagWith(tag.ID(l)))
tagPredicates := lo.Map(descendantGroups[0], func(l uuid.UUID, _ int) predicate.Entity {
return entity.HasTagWith(tag.ID(l))
})
andPredicates = append(andPredicates, entity.And(tagPredicates...))
if q.NegateTags {
andPredicates = append(andPredicates, entity.Not(entity.Or(tagPredicates...)))
} else {
andPredicates = append(andPredicates, entity.Or(tagPredicates...))
}
}
}

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
51 changes: 51 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,57 @@ 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, 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().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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

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
14 changes: 14 additions & 0 deletions frontend/pages/items.vue
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@
const includeArchived = useOptionalRouteQuery("archived", false);
const fieldSelector = useOptionalRouteQuery("fieldSelector", false);
const negateTags = useOptionalRouteQuery("negateTags", false);
const matchAllTags = useOptionalRouteQuery("matchAllTags", false);
const onlyWithoutPhoto = useOptionalRouteQuery("onlyWithoutPhoto", false);
const onlyWithPhoto = useOptionalRouteQuery("onlyWithPhoto", false);
const orderBy = useOptionalRouteQuery("orderBy", "name");
Expand Down Expand Up @@ -207,6 +208,12 @@
}
});

watch(matchAllTags, (newV, oldV) => {
if (newV !== oldV) {
search();
}
});

watch(onlyWithoutPhoto, (newV, oldV) => {
if (newV && onlyWithPhoto.value) {
// this triggers the watch on onlyWithPhoto
Expand Down Expand Up @@ -275,6 +282,7 @@
archived: includeArchived.value,
fieldSelector: fieldSelector.value,
negateTags: negateTags.value,
matchAllTags: matchAllTags.value,
onlyWithoutPhoto: onlyWithoutPhoto.value,
onlyWithPhoto: onlyWithPhoto.value,
orderBy: orderBy.value,
Expand Down Expand Up @@ -312,6 +320,7 @@
parentIds: locIDs.value,
tags: tagIDs.value,
negateTags: negateTags.value,
matchAllTags: matchAllTags.value,
onlyWithoutPhoto: onlyWithoutPhoto.value,
onlyWithPhoto: onlyWithPhoto.value,
includeArchived: includeArchived.value,
Expand Down Expand Up @@ -426,6 +435,11 @@
<div class="grow" />
<span class="text-right"> {{ $t("items.negate_tags") }} </span>
</Label>
<Label class="flex cursor-pointer items-center">
<Switch v-model="matchAllTags" class="ml-auto" />
<div class="grow" />
<span class="text-right"> {{ $t("items.match_all_tags") }} </span>
</Label>
<Label class="flex cursor-pointer items-center">
<Switch v-model="onlyWithoutPhoto" class="ml-auto" />
<div class="grow" />
Expand Down
Loading