Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
4 changes: 4 additions & 0 deletions .github/workflows/partial-frontend.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ jobs:
with:
fetch-depth: 0

- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f
with:
node-version: lts/*

- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061
with:
version: 10
Expand Down
22 changes: 14 additions & 8 deletions backend/app/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -226,14 +226,7 @@ func run(cfg *config.Config) error {
app.db = c
app.repos = repo.New(c, app.bus, cfg.Storage, cfg.Database.PubSubConnString, cfg.Thumbnail)

// Attachment-key escaping in fileblob only flattens paths on Windows
// (where os.PathSeparator is "\"), so the legacy-path rename is a Windows-
// only concern; skip the disk scan everywhere else.
if runtime.GOOS == "windows" {
if err := app.repos.Attachments.MigrateLegacyFlatPaths(); err != nil {
log.Error().Err(err).Msg("failed to migrate legacy attachment file paths")
}
}
migrateLegacyAttachmentPaths(app)

app.services = services.New(
app.repos,
Expand Down Expand Up @@ -356,6 +349,19 @@ func run(cfg *config.Config) error {
return runner.Start(context.Background())
}

func migrateLegacyAttachmentPaths(app *app) {
// Attachment-key escaping in fileblob only flattens paths on Windows
// (where os.PathSeparator is "\"), so the legacy-path rename is a Windows-
// only concern; skip the disk scan everywhere else.
if runtime.GOOS != "windows" {
return
}

if err := app.repos.Attachments.MigrateLegacyFlatPaths(); err != nil {
log.Error().Err(err).Msg("failed to migrate legacy attachment file paths")
}
}

// ensureAssetIDs assigns asset IDs to any entities that don't have one,
// covering locations that were migrated from the old schema.
func ensureAssetIDs(app *app) {
Expand Down
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
2 changes: 1 addition & 1 deletion frontend/components/Location/CreateModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import BaseModal from "@/components/App/CreateModal.vue";
import type { EntityTypeSummary, EntitySummary } from "~~/lib/api/types/data-contracts";
import type { EntitySummary } from "~~/lib/api/types/data-contracts";
import { AttachmentTypes } from "~~/lib/api/types/non-generated";
import { useDialog, useDialogHotkey } from "~/components/ui/dialog-provider";
import { useTagStore } from "~/stores/tags";
Expand Down
Loading
Loading