Skip to content

Commit 0818bda

Browse files
phernandezclaude
andcommitted
feat: Add bulk insert with ON CONFLICT handling for relations
Add add_all_ignore_duplicates() method to RelationRepository for bulk inserting relations with ON CONFLICT DO NOTHING. This handles cases where the same [[wiki link]] appears multiple times in a document, silently ignoring duplicates based on the (from_id, to_name, relation_type) unique constraint. Works with both SQLite and PostgreSQL dialects. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: phernandez <paul@basicmachines.co>
1 parent 6f99d2e commit 0818bda

2 files changed

Lines changed: 206 additions & 0 deletions

File tree

src/basic_memory/repository/relation_repository.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
import logfire
66
from sqlalchemy import and_, delete, select
7+
from sqlalchemy.dialects.postgresql import insert as pg_insert
8+
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
79
from sqlalchemy.ext.asyncio import async_sessionmaker
810
from sqlalchemy.orm import selectinload, aliased
911
from sqlalchemy.orm.interfaces import LoaderOption
@@ -92,5 +94,56 @@ async def find_unresolved_relations_for_entity(self, entity_id: int) -> Sequence
9294
result = await self.execute_query(query)
9395
return result.scalars().all()
9496

97+
@logfire.instrument()
98+
async def add_all_ignore_duplicates(self, relations: List[Relation]) -> int:
99+
"""Bulk insert relations, ignoring duplicates.
100+
101+
Uses ON CONFLICT DO NOTHING to skip relations that would violate the
102+
unique constraint on (from_id, to_name, relation_type). This is useful
103+
for bulk operations where the same link may appear multiple times in
104+
a document.
105+
106+
Works with both SQLite and PostgreSQL dialects.
107+
108+
Args:
109+
relations: List of Relation objects to insert
110+
111+
Returns:
112+
Number of relations actually inserted (excludes duplicates)
113+
"""
114+
if not relations:
115+
return 0
116+
117+
# Convert Relation objects to dicts for insert
118+
values = [
119+
{
120+
"from_id": r.from_id,
121+
"to_id": r.to_id,
122+
"to_name": r.to_name,
123+
"relation_type": r.relation_type,
124+
"context": r.context,
125+
}
126+
for r in relations
127+
]
128+
129+
async with db.scoped_session(self.session_maker) as session:
130+
# Check dialect to use appropriate insert
131+
dialect_name = session.bind.dialect.name if session.bind else "sqlite"
132+
133+
if dialect_name == "postgresql":
134+
stmt = pg_insert(Relation).values(values)
135+
stmt = stmt.on_conflict_do_nothing(
136+
index_elements=["from_id", "to_name", "relation_type"]
137+
)
138+
else:
139+
# SQLite
140+
stmt = sqlite_insert(Relation).values(values)
141+
stmt = stmt.on_conflict_do_nothing(
142+
index_elements=["from_id", "to_name", "relation_type"]
143+
)
144+
145+
result = await session.execute(stmt)
146+
return result.rowcount if result.rowcount else 0
147+
95148
def get_load_options(self) -> List[LoaderOption]:
96149
return [selectinload(Relation.from_entity), selectinload(Relation.to_entity)]

tests/repository/test_relation_repository.py

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,3 +349,156 @@ async def test_delete_nonexistent_relation(relation_repository):
349349
"""Test deleting a relation that doesn't exist."""
350350
result = await relation_repository.delete_by_fields(relation_type="nonexistent")
351351
assert result is False
352+
353+
354+
# -------------------------------------------------------------------------
355+
# Tests for add_all_ignore_duplicates
356+
# -------------------------------------------------------------------------
357+
358+
359+
@pytest.mark.asyncio
360+
async def test_add_all_ignore_duplicates_basic(
361+
relation_repository: RelationRepository, sample_entity: Entity, related_entity: Entity
362+
):
363+
"""Test bulk inserting relations with ON CONFLICT DO NOTHING."""
364+
relations = [
365+
Relation(
366+
from_id=sample_entity.id,
367+
to_id=related_entity.id,
368+
to_name=related_entity.title,
369+
relation_type="links_to",
370+
),
371+
Relation(
372+
from_id=sample_entity.id,
373+
to_id=related_entity.id,
374+
to_name=related_entity.title,
375+
relation_type="references",
376+
),
377+
]
378+
379+
inserted = await relation_repository.add_all_ignore_duplicates(relations)
380+
381+
# Both should be inserted
382+
assert inserted == 2
383+
384+
# Verify they exist
385+
found = await relation_repository.find_by_entities(sample_entity.id, related_entity.id)
386+
assert len(found) == 2
387+
relation_types = {r.relation_type for r in found}
388+
assert relation_types == {"links_to", "references"}
389+
390+
391+
@pytest.mark.asyncio
392+
async def test_add_all_ignore_duplicates_skips_duplicates(
393+
relation_repository: RelationRepository, sample_entity: Entity, related_entity: Entity
394+
):
395+
"""Test that duplicate relations are silently ignored."""
396+
# Same relation appearing multiple times (common when same [[link]] appears twice in doc)
397+
relations = [
398+
Relation(
399+
from_id=sample_entity.id,
400+
to_id=None, # Unresolved
401+
to_name="Some Target",
402+
relation_type="links_to",
403+
),
404+
Relation(
405+
from_id=sample_entity.id,
406+
to_id=None,
407+
to_name="Some Target", # Duplicate!
408+
relation_type="links_to",
409+
),
410+
Relation(
411+
from_id=sample_entity.id,
412+
to_id=None,
413+
to_name="Some Target", # Triple duplicate!
414+
relation_type="links_to",
415+
),
416+
]
417+
418+
inserted = await relation_repository.add_all_ignore_duplicates(relations)
419+
420+
# Only 1 should be inserted (duplicates ignored)
421+
assert inserted == 1
422+
423+
# Verify only one exists
424+
all_relations = await relation_repository.find_all()
425+
matching = [r for r in all_relations if r.to_name == "Some Target"]
426+
assert len(matching) == 1
427+
428+
429+
@pytest.mark.asyncio
430+
async def test_add_all_ignore_duplicates_empty_list(relation_repository: RelationRepository):
431+
"""Test with empty list returns 0."""
432+
inserted = await relation_repository.add_all_ignore_duplicates([])
433+
assert inserted == 0
434+
435+
436+
@pytest.mark.asyncio
437+
async def test_add_all_ignore_duplicates_mixed(
438+
relation_repository: RelationRepository, sample_entity: Entity, related_entity: Entity
439+
):
440+
"""Test with mix of new and duplicate relations."""
441+
# First, insert one relation
442+
first_relation = Relation(
443+
from_id=sample_entity.id,
444+
to_id=None,
445+
to_name="Existing Target",
446+
relation_type="links_to",
447+
)
448+
await relation_repository.add_all_ignore_duplicates([first_relation])
449+
450+
# Now try to insert a mix of new and duplicate
451+
relations = [
452+
Relation(
453+
from_id=sample_entity.id,
454+
to_id=None,
455+
to_name="Existing Target", # Duplicate of first_relation
456+
relation_type="links_to",
457+
),
458+
Relation(
459+
from_id=sample_entity.id,
460+
to_id=None,
461+
to_name="New Target 1", # New
462+
relation_type="links_to",
463+
),
464+
Relation(
465+
from_id=sample_entity.id,
466+
to_id=None,
467+
to_name="New Target 2", # New
468+
relation_type="references",
469+
),
470+
]
471+
472+
inserted = await relation_repository.add_all_ignore_duplicates(relations)
473+
474+
# Only 2 new ones should be inserted
475+
assert inserted == 2
476+
477+
# Verify total count
478+
all_relations = await relation_repository.find_all()
479+
from_sample = [r for r in all_relations if r.from_id == sample_entity.id]
480+
assert len(from_sample) == 3 # 1 existing + 2 new
481+
482+
483+
@pytest.mark.asyncio
484+
async def test_add_all_ignore_duplicates_with_context(
485+
relation_repository: RelationRepository, sample_entity: Entity, related_entity: Entity
486+
):
487+
"""Test that context field is properly inserted."""
488+
relations = [
489+
Relation(
490+
from_id=sample_entity.id,
491+
to_id=related_entity.id,
492+
to_name=related_entity.title,
493+
relation_type="links_to",
494+
context="some context here",
495+
),
496+
]
497+
498+
inserted = await relation_repository.add_all_ignore_duplicates(relations)
499+
assert inserted == 1
500+
501+
# Verify context was saved
502+
found = await relation_repository.find_by_entities(sample_entity.id, related_entity.id)
503+
assert len(found) == 1
504+
assert found[0].context == "some context here"

0 commit comments

Comments
 (0)