477 lines
14 KiB
Go
477 lines
14 KiB
Go
package mongo
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
|
|
"go.mongodb.org/mongo-driver/v2/bson"
|
|
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
|
|
|
"github.com/andydixon/relata/internal/domain"
|
|
"github.com/andydixon/relata/internal/ids"
|
|
)
|
|
|
|
// RelationshipRepo stores relationships and their review history.
|
|
type RelationshipRepo struct{ base }
|
|
|
|
var _ domain.RelationshipRepository = (*RelationshipRepo)(nil)
|
|
|
|
// FindByDedupeKey returns the relationship with the given dedupe key.
|
|
func (r *RelationshipRepo) FindByDedupeKey(ctx context.Context, workspaceID, dedupeKey string) (*domain.Relationship, error) {
|
|
ctx, cancel := r.ctx(ctx)
|
|
defer cancel()
|
|
started := time.Now()
|
|
|
|
f, err := wsFilter(workspaceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if dedupeKey == "" {
|
|
return nil, fmt.Errorf("%w: dedupe key must not be empty", domain.ErrInvalidInput)
|
|
}
|
|
f = append(f, bson.E{Key: "dedupe_key", Value: dedupeKey})
|
|
|
|
out, err := r.findOne(ctx, notDeleted(f))
|
|
r.observe("find_by_dedupe_key", started, err)
|
|
return out, err
|
|
}
|
|
|
|
// UpsertByDedupeKey inserts or refreshes a discovered relationship.
|
|
//
|
|
// Two properties matter here and are the reason this is not a plain upsert:
|
|
//
|
|
// - Reprocessing must not create duplicates. The dedupe key is derived from
|
|
// the endpoints and type, and a unique index enforces one document per key.
|
|
// - A human decision is never overwritten by a later analysis run. When the
|
|
// relationship has been confirmed, rejected or edited, only the evidence is
|
|
// refreshed; the type, provenance and review status stay as the person left
|
|
// them, and the score keeps reflecting their decision.
|
|
func (r *RelationshipRepo) UpsertByDedupeKey(ctx context.Context, rel *domain.Relationship) (*domain.Relationship, error) {
|
|
ctx, cancel := r.ctx(ctx)
|
|
defer cancel()
|
|
started := time.Now()
|
|
|
|
f, err := wsFilter(rel.WorkspaceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
f = append(f, bson.E{Key: "dedupe_key", Value: rel.DedupeKey})
|
|
|
|
// If a person has already ruled on this relationship, refresh only the
|
|
// evidence and leave their decision intact.
|
|
existing, err := r.findOne(ctx, f)
|
|
if err != nil && err != domain.ErrNotFound {
|
|
return nil, err
|
|
}
|
|
if existing != nil && existing.ReviewStatus != domain.ReviewPending {
|
|
update := bson.D{{Key: "$set", Value: bson.D{
|
|
{Key: "evidence", Value: rel.Evidence},
|
|
{Key: "updated_at", Value: time.Now().UTC()},
|
|
}}}
|
|
if _, err := r.coll.UpdateOne(ctx, f, update); err != nil {
|
|
r.observe("upsert", started, err)
|
|
return nil, translate(err)
|
|
}
|
|
refreshed, err := r.findOne(ctx, f)
|
|
r.observe("upsert", started, err)
|
|
return refreshed, err
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
if rel.ID == "" {
|
|
rel.ID = ids.New()
|
|
}
|
|
|
|
update := bson.D{
|
|
{Key: "$setOnInsert", Value: bson.D{
|
|
{Key: "_id", Value: rel.ID},
|
|
{Key: "workspace_id", Value: rel.WorkspaceID},
|
|
{Key: "dedupe_key", Value: rel.DedupeKey},
|
|
{Key: "source_kind", Value: string(rel.SourceKind)},
|
|
{Key: "source_id", Value: rel.SourceID},
|
|
{Key: "target_kind", Value: string(rel.TargetKind)},
|
|
{Key: "target_id", Value: rel.TargetID},
|
|
{Key: "original_type", Value: string(rel.Type)},
|
|
{Key: "original_provenance", Value: string(rel.Provenance)},
|
|
{Key: "review_status", Value: string(domain.ReviewPending)},
|
|
{Key: "created_at", Value: now},
|
|
{Key: "deleted", Value: false},
|
|
}},
|
|
{Key: "$set", Value: bson.D{
|
|
{Key: "type", Value: string(rel.Type)},
|
|
{Key: "provenance", Value: string(rel.Provenance)},
|
|
{Key: "score", Value: rel.Score},
|
|
{Key: "score_version", Value: rel.ScoreVersion},
|
|
{Key: "evidence", Value: rel.Evidence},
|
|
{Key: "updated_at", Value: now},
|
|
}},
|
|
}
|
|
|
|
opts := options.FindOneAndUpdate().SetUpsert(true).SetReturnDocument(options.After)
|
|
var out domain.Relationship
|
|
err = r.coll.FindOneAndUpdate(ctx, f, update, opts).Decode(&out)
|
|
r.observe("upsert", started, err)
|
|
if err != nil {
|
|
return nil, translate(err)
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
func (r *RelationshipRepo) findOne(ctx context.Context, f bson.D) (*domain.Relationship, error) {
|
|
var rel domain.Relationship
|
|
if err := r.coll.FindOne(ctx, f).Decode(&rel); err != nil {
|
|
return nil, translate(err)
|
|
}
|
|
return &rel, nil
|
|
}
|
|
|
|
// Create inserts a relationship, typically one a user added by hand.
|
|
func (r *RelationshipRepo) Create(ctx context.Context, rel *domain.Relationship) error {
|
|
ctx, cancel := r.ctx(ctx)
|
|
defer cancel()
|
|
started := time.Now()
|
|
|
|
if rel.WorkspaceID == "" {
|
|
return domain.ErrWorkspaceMiss
|
|
}
|
|
_, err := r.coll.InsertOne(ctx, rel)
|
|
r.observe("create", started, err)
|
|
return translate(err)
|
|
}
|
|
|
|
// Get fetches one relationship within a workspace.
|
|
func (r *RelationshipRepo) Get(ctx context.Context, workspaceID, id string) (*domain.Relationship, error) {
|
|
ctx, cancel := r.ctx(ctx)
|
|
defer cancel()
|
|
started := time.Now()
|
|
|
|
f, err := wsFilter(workspaceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
f = append(f, bson.E{Key: "_id", Value: id})
|
|
|
|
rel, err := r.findOne(ctx, f)
|
|
r.observe("get", started, err)
|
|
return rel, err
|
|
}
|
|
|
|
// buildRelFilter converts a typed filter into BSON.
|
|
func buildRelFilter(workspaceID string, rf domain.RelationshipFilter) (bson.D, error) {
|
|
f, err := wsFilter(workspaceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
f = notDeleted(f)
|
|
|
|
if len(rf.Types) > 0 {
|
|
vals := make([]any, 0, len(rf.Types))
|
|
for _, t := range rf.Types {
|
|
if t.Valid() {
|
|
vals = append(vals, string(t))
|
|
}
|
|
}
|
|
if len(vals) > 0 {
|
|
f = append(f, bson.E{Key: "type", Value: bson.D{{Key: "$in", Value: vals}}})
|
|
}
|
|
}
|
|
if len(rf.Provenances) > 0 {
|
|
vals := make([]any, 0, len(rf.Provenances))
|
|
for _, p := range rf.Provenances {
|
|
if p.Valid() {
|
|
vals = append(vals, string(p))
|
|
}
|
|
}
|
|
if len(vals) > 0 {
|
|
f = append(f, bson.E{Key: "provenance", Value: bson.D{{Key: "$in", Value: vals}}})
|
|
}
|
|
}
|
|
if rf.ConfirmedOnly {
|
|
f = append(f, bson.E{Key: "review_status", Value: string(domain.ReviewConfirmed)})
|
|
} else if len(rf.ReviewStatus) > 0 {
|
|
vals := make([]any, 0, len(rf.ReviewStatus))
|
|
for _, s := range rf.ReviewStatus {
|
|
vals = append(vals, string(s))
|
|
}
|
|
f = append(f, bson.E{Key: "review_status", Value: bson.D{{Key: "$in", Value: vals}}})
|
|
}
|
|
if rf.MinScore > 0 {
|
|
f = append(f, bson.E{Key: "score", Value: bson.D{{Key: "$gte", Value: rf.MinScore}}})
|
|
}
|
|
if rf.NodeID != "" {
|
|
switch rf.Direction {
|
|
case "out":
|
|
f = append(f, bson.E{Key: "source_id", Value: rf.NodeID})
|
|
case "in":
|
|
f = append(f, bson.E{Key: "target_id", Value: rf.NodeID})
|
|
default:
|
|
f = append(f, bson.E{Key: "$or", Value: bson.A{
|
|
bson.D{{Key: "source_id", Value: rf.NodeID}},
|
|
bson.D{{Key: "target_id", Value: rf.NodeID}},
|
|
}})
|
|
}
|
|
}
|
|
return f, nil
|
|
}
|
|
|
|
// List returns a page of relationships.
|
|
func (r *RelationshipRepo) List(ctx context.Context, workspaceID string, rf domain.RelationshipFilter, p domain.Page) ([]*domain.Relationship, int64, error) {
|
|
ctx, cancel := r.ctx(ctx)
|
|
defer cancel()
|
|
started := time.Now()
|
|
|
|
f, err := buildRelFilter(workspaceID, rf)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
p = p.Normalise(25, 200)
|
|
|
|
total, err := r.coll.CountDocuments(ctx, f)
|
|
if err != nil {
|
|
r.observe("list", started, err)
|
|
return nil, 0, translate(err)
|
|
}
|
|
cur, err := r.coll.Find(ctx, f, options.Find().
|
|
SetSort(bson.D{{Key: "score", Value: -1}, {Key: "_id", Value: 1}}).
|
|
SetSkip(int64(p.Offset)).SetLimit(int64(p.Limit)))
|
|
r.observe("list", started, err)
|
|
if err != nil {
|
|
return nil, 0, translate(err)
|
|
}
|
|
var out []*domain.Relationship
|
|
if err := cur.All(ctx, &out); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
return out, total, nil
|
|
}
|
|
|
|
// ListForNode returns a node's outgoing and incoming relationships separately,
|
|
// which is what the item detail page shows.
|
|
func (r *RelationshipRepo) ListForNode(ctx context.Context, workspaceID string, kind domain.NodeKind, id string, rf domain.RelationshipFilter) (out, in []*domain.Relationship, err error) {
|
|
ctx, cancel := r.ctx(ctx)
|
|
defer cancel()
|
|
started := time.Now()
|
|
|
|
fetch := func(direction string) ([]*domain.Relationship, error) {
|
|
sub := rf
|
|
sub.NodeID = id
|
|
sub.NodeKind = kind
|
|
sub.Direction = direction
|
|
f, err := buildRelFilter(workspaceID, sub)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
cur, err := r.coll.Find(ctx, f, options.Find().
|
|
SetSort(bson.D{{Key: "score", Value: -1}}).SetLimit(200))
|
|
if err != nil {
|
|
return nil, translate(err)
|
|
}
|
|
var rels []*domain.Relationship
|
|
if err := cur.All(ctx, &rels); err != nil {
|
|
return nil, err
|
|
}
|
|
return rels, nil
|
|
}
|
|
|
|
out, err = fetch("out")
|
|
if err != nil {
|
|
r.observe("list_for_node", started, err)
|
|
return nil, nil, err
|
|
}
|
|
in, err = fetch("in")
|
|
r.observe("list_for_node", started, err)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return out, in, nil
|
|
}
|
|
|
|
// ListForItems fetches relationships touching any of the given items, used for
|
|
// relationship expansion during search.
|
|
func (r *RelationshipRepo) ListForItems(ctx context.Context, workspaceID string, itemIDs []string, minScore float64) ([]*domain.Relationship, error) {
|
|
if len(itemIDs) == 0 {
|
|
return nil, nil
|
|
}
|
|
ctx, cancel := r.ctx(ctx)
|
|
defer cancel()
|
|
started := time.Now()
|
|
|
|
f, err := wsFilter(workspaceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
f = notDeleted(f)
|
|
vals := stringsToAny(itemIDs)
|
|
f = append(f, bson.E{Key: "$or", Value: bson.A{
|
|
bson.D{{Key: "source_id", Value: bson.D{{Key: "$in", Value: vals}}}},
|
|
bson.D{{Key: "target_id", Value: bson.D{{Key: "$in", Value: vals}}}},
|
|
}})
|
|
if minScore > 0 {
|
|
f = append(f, bson.E{Key: "score", Value: bson.D{{Key: "$gte", Value: minScore}}})
|
|
}
|
|
// Rejected relationships are excluded from expansion: a person has said
|
|
// this link is wrong, so it must not pull results in.
|
|
f = append(f, bson.E{Key: "review_status", Value: bson.D{{Key: "$ne", Value: string(domain.ReviewRejected)}}})
|
|
|
|
cur, err := r.coll.Find(ctx, f, options.Find().
|
|
SetSort(bson.D{{Key: "score", Value: -1}}).SetLimit(500))
|
|
r.observe("list_for_items", started, err)
|
|
if err != nil {
|
|
return nil, translate(err)
|
|
}
|
|
var rels []*domain.Relationship
|
|
if err := cur.All(ctx, &rels); err != nil {
|
|
return nil, err
|
|
}
|
|
return rels, nil
|
|
}
|
|
|
|
// Update replaces a relationship document.
|
|
func (r *RelationshipRepo) Update(ctx context.Context, rel *domain.Relationship) error {
|
|
ctx, cancel := r.ctx(ctx)
|
|
defer cancel()
|
|
started := time.Now()
|
|
|
|
f, err := wsFilter(rel.WorkspaceID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
f = append(f, bson.E{Key: "_id", Value: rel.ID})
|
|
rel.UpdatedAt = time.Now().UTC()
|
|
|
|
res, err := r.coll.ReplaceOne(ctx, f, rel)
|
|
r.observe("update", started, err)
|
|
if err != nil {
|
|
return translate(err)
|
|
}
|
|
if res.MatchedCount == 0 {
|
|
return domain.ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// AppendReview records a human decision.
|
|
//
|
|
// The review history is append-only and the original proposal fields are never
|
|
// touched, so rejecting a relationship documents the disagreement rather than
|
|
// erasing what analysis suggested.
|
|
func (r *RelationshipRepo) AppendReview(ctx context.Context, workspaceID, id string, ev domain.ReviewEvent, newStatus domain.ReviewStatus, newType domain.RelationshipType, newProv domain.Provenance) (*domain.Relationship, error) {
|
|
ctx, cancel := r.ctx(ctx)
|
|
defer cancel()
|
|
started := time.Now()
|
|
|
|
f, err := wsFilter(workspaceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
f = append(f, bson.E{Key: "_id", Value: id})
|
|
|
|
set := bson.D{
|
|
{Key: "review_status", Value: string(newStatus)},
|
|
{Key: "updated_at", Value: time.Now().UTC()},
|
|
}
|
|
if newType != "" {
|
|
set = append(set, bson.E{Key: "type", Value: string(newType)})
|
|
}
|
|
if newProv != "" {
|
|
set = append(set, bson.E{Key: "provenance", Value: string(newProv)})
|
|
}
|
|
|
|
update := bson.D{
|
|
{Key: "$set", Value: set},
|
|
{Key: "$push", Value: bson.D{{Key: "review_history", Value: ev}}},
|
|
}
|
|
|
|
opts := options.FindOneAndUpdate().SetReturnDocument(options.After)
|
|
var out domain.Relationship
|
|
err = r.coll.FindOneAndUpdate(ctx, f, update, opts).Decode(&out)
|
|
r.observe("append_review", started, err)
|
|
if err != nil {
|
|
return nil, translate(err)
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
// TombstoneForItem marks an item's relationships deleted while retaining them.
|
|
func (r *RelationshipRepo) TombstoneForItem(ctx context.Context, workspaceID, itemID string) (int64, error) {
|
|
ctx, cancel := r.ctx(ctx)
|
|
defer cancel()
|
|
started := time.Now()
|
|
|
|
f, err := r.itemFilter(workspaceID, itemID)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
res, err := r.coll.UpdateMany(ctx, f, bson.D{{Key: "$set", Value: bson.D{
|
|
{Key: "deleted", Value: true},
|
|
{Key: "updated_at", Value: time.Now().UTC()},
|
|
}}})
|
|
r.observe("tombstone_for_item", started, err)
|
|
if err != nil {
|
|
return 0, translate(err)
|
|
}
|
|
return res.ModifiedCount, nil
|
|
}
|
|
|
|
// DeleteForItem removes an item's relationships outright.
|
|
func (r *RelationshipRepo) DeleteForItem(ctx context.Context, workspaceID, itemID string) (int64, error) {
|
|
ctx, cancel := r.ctx(ctx)
|
|
defer cancel()
|
|
started := time.Now()
|
|
|
|
f, err := r.itemFilter(workspaceID, itemID)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
res, err := r.coll.DeleteMany(ctx, f)
|
|
r.observe("delete_for_item", started, err)
|
|
if err != nil {
|
|
return 0, translate(err)
|
|
}
|
|
return res.DeletedCount, nil
|
|
}
|
|
|
|
func (r *RelationshipRepo) itemFilter(workspaceID, itemID string) (bson.D, error) {
|
|
f, err := wsFilter(workspaceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return append(f, bson.E{Key: "$or", Value: bson.A{
|
|
bson.D{{Key: "source_id", Value: itemID}},
|
|
bson.D{{Key: "target_id", Value: itemID}},
|
|
}}), nil
|
|
}
|
|
|
|
// Count returns the number of live relationships in a workspace.
|
|
func (r *RelationshipRepo) Count(ctx context.Context, workspaceID string) (int64, error) {
|
|
ctx, cancel := r.ctx(ctx)
|
|
defer cancel()
|
|
started := time.Now()
|
|
|
|
f, err := wsFilter(workspaceID)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
n, err := r.coll.CountDocuments(ctx, notDeleted(f))
|
|
r.observe("count", started, err)
|
|
return n, translate(err)
|
|
}
|
|
|
|
// CountPendingReview returns how many relationships await human review.
|
|
func (r *RelationshipRepo) CountPendingReview(ctx context.Context, workspaceID string) (int64, error) {
|
|
ctx, cancel := r.ctx(ctx)
|
|
defer cancel()
|
|
started := time.Now()
|
|
|
|
f, err := wsFilter(workspaceID)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
f = notDeleted(append(f, bson.E{Key: "review_status", Value: string(domain.ReviewPending)}))
|
|
|
|
n, err := r.coll.CountDocuments(ctx, f)
|
|
r.observe("count_pending", started, err)
|
|
return n, translate(err)
|
|
}
|