Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
54 changes: 40 additions & 14 deletions backend/app/services/relationship_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,23 @@
"hasPart": "dct_isPartOf_sm",
"pcdm:hasMember": "pcdm_memberOf_sm",
"hasMember": "pcdm_memberOf_sm",
"dct:isSourceOf": "dct_source_sm",
"isSourceOf": "dct_source_sm",
}
RELATIONSHIP_PREDICATE_ALIASES = {
"dct:sourceOf": "dct:isSourceOf",
"sourceOf": "isSourceOf",
}
PUBLICATION_STATE_PUBLISHED = "published"


def _canonical_relationship_predicate(predicate: Any) -> str:
value = str(predicate)
return RELATIONSHIP_PREDICATE_ALIASES.get(value, value)


def _relationship_browse_link(resource_id: str, predicate: str) -> str | None:
facet_field = RELATIONSHIP_BROWSE_FACET_FIELDS.get(predicate)
facet_field = RELATIONSHIP_BROWSE_FACET_FIELDS.get(_canonical_relationship_predicate(predicate))
if not facet_field:
return None
return f"/search?include_filters[{facet_field}][]={quote(str(resource_id), safe='')}"
Expand Down Expand Up @@ -153,13 +164,21 @@ async def get_resource_relationships_map(
)

relationships_by_id: Dict[str, Dict] = {}
seen_relationships: set[tuple[str, str, str]] = set()

for rel in db_relationships:
subject_id = str(rel["subject_id"])
predicate = _canonical_relationship_predicate(rel["predicate"])
object_id = str(rel["object_id"])
relationship_key = (subject_id, predicate, object_id)
if relationship_key in seen_relationships:
continue
seen_relationships.add(relationship_key)

relationships = relationships_by_id.setdefault(subject_id, {})
if rel["predicate"] not in relationships:
relationships[rel["predicate"]] = []
relationships[rel["predicate"]].append(
if predicate not in relationships:
relationships[predicate] = []
relationships[predicate].append(
{
"resource_id": rel["object_id"],
"resource_title": rel["dct_title_s"],
Expand All @@ -169,7 +188,7 @@ async def get_resource_relationships_map(
logger.debug(
"Added relationship for %s: %s -> %s",
subject_id,
rel["predicate"],
predicate,
rel["object_id"],
)

Expand All @@ -188,10 +207,12 @@ async def get_resource_relationship_summaries_map(
)

summaries_by_id: Dict[str, Dict[str, Any]] = {}
seen_relationships: set[tuple[str, str, str]] = set()

for rel in db_relationships:
subject_id = str(rel["subject_id"])
predicate = str(rel["predicate"])
predicate = _canonical_relationship_predicate(rel["predicate"])
object_id = str(rel["object_id"])
summary = summaries_by_id.setdefault(
subject_id,
{
Expand All @@ -201,16 +222,21 @@ async def get_resource_relationship_summaries_map(
},
)
relationships = summary["relationships"].setdefault(predicate, [])
relationships.append(
{
"resource_id": rel["object_id"],
"resource_title": rel["dct_title_s"],
"link": f"/resources/{rel['object_id']}",
}
)
relationship_key = (subject_id, predicate, object_id)
if relationship_key not in seen_relationships:
seen_relationships.add(relationship_key)
relationships.append(
{
"resource_id": rel["object_id"],
"resource_title": rel["dct_title_s"],
"link": f"/resources/{rel['object_id']}",
}
)

total_count = _record_get(rel, "total_count", len(relationships))
summary["counts"][predicate] = int(total_count)
summary["counts"][predicate] = max(
summary["counts"].get(predicate, 0), int(total_count)
)

browse_link = _relationship_browse_link(subject_id, predicate)
if browse_link:
Expand Down
8 changes: 7 additions & 1 deletion backend/app/services/relationship_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,25 @@
("dct_relation_sm", "dct:relation", "dct:relation"),
("dct_isPartOf_sm", "dct:isPartOf", "dct:hasPart"),
("pcdm_memberOf_sm", "pcdm:memberOf", "pcdm:hasMember"),
("dct_source_sm", "dct:source", "dct:sourceOf"),
("dct_source_sm", "dct:source", "dct:isSourceOf"),
("dct_isVersionOf_sm", "dct:isVersionOf", "dct:hasVersion"),
("dct_replaces_sm", "dct:replaces", "dct:isReplacedBy"),
("dct_isReplacedBy_sm", "dct:isReplacedBy", "dct:replaces"),
)

# Incremental relationship sync briefly used dct:sourceOf for the inverse of
# dct:source. Keep it in the cleanup set so a subsequent sync removes those
# legacy rows while emitting only the canonical dct:isSourceOf predicate.
LEGACY_RELATIONSHIP_PREDICATES: Tuple[str, ...] = ("dct:sourceOf",)

ALL_RELATIONSHIP_PREDICATES: Tuple[str, ...] = tuple(
sorted(
{
predicate
for _, predicate, inverse in RELATIONSHIP_FAMILIES
for predicate in (predicate, inverse)
}
| set(LEGACY_RELATIONSHIP_PREDICATES)
)
)

Expand Down
66 changes: 66 additions & 0 deletions backend/tests/services/test_relationship_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,72 @@ async def fake_fetch_relationship_rows(resource_ids, *, limit_per_predicate=None
"link": "/resources/part-1",
}

@pytest.mark.asyncio
async def test_source_of_aliases_are_canonicalized_and_deduplicated(self, monkeypatch):
async def fake_fetch_relationship_rows(resource_ids, *, limit_per_predicate=None):
assert list(resource_ids) == ["parent-record"]
assert limit_per_predicate is None
return [
{
"subject_id": "parent-record",
"predicate": predicate,
"object_id": "child-record",
"dct_title_s": "Child record",
}
for predicate in ("dct:isSourceOf", "dct:sourceOf")
]

monkeypatch.setattr(
RelationshipService,
"_fetch_relationship_rows",
staticmethod(fake_fetch_relationship_rows),
)

relationships = await RelationshipService.get_resource_relationships("parent-record")

assert relationships == {
"dct:isSourceOf": [
{
"resource_id": "child-record",
"resource_title": "Child record",
"link": "/resources/child-record",
}
]
}

@pytest.mark.asyncio
async def test_source_of_summary_uses_derived_records_browse_filter(self, monkeypatch):
async def fake_fetch_relationship_rows(resource_ids, *, limit_per_predicate=None):
assert list(resource_ids) == ["parent-record"]
assert limit_per_predicate == 5
return [
{
"subject_id": "parent-record",
"predicate": predicate,
"object_id": "child-record",
"dct_title_s": "Child record",
"total_count": 20,
}
for predicate in ("dct:isSourceOf", "dct:sourceOf")
]

monkeypatch.setattr(
RelationshipService,
"_fetch_relationship_rows",
staticmethod(fake_fetch_relationship_rows),
)

summaries = await RelationshipService.get_resource_relationship_summaries_map(
["parent-record"]
)

summary = summaries["parent-record"]
assert len(summary["relationships"]["dct:isSourceOf"]) == 1
assert summary["counts"] == {"dct:isSourceOf": 20}
assert summary["browse_links"] == {
"dct:isSourceOf": ("/search?include_filters[dct_source_sm][]=parent-record")
}

@pytest.mark.asyncio
async def test_get_resource_relationships_with_real_database(self):
"""Test getting resource relationships using real database connection."""
Expand Down
35 changes: 35 additions & 0 deletions backend/tests/services/test_relationship_sync.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from app.services.relationship_sync import (
ALL_RELATIONSHIP_PREDICATES,
RELATIONSHIP_FAMILIES,
_build_relationship_rows,
)


def test_source_relationships_use_canonical_parent_and_child_predicates():
source_family = next(family for family in RELATIONSHIP_FAMILIES if family[0] == "dct_source_sm")

rows = _build_relationship_rows(
{
source_family: [
{
"id": "child-record",
"dct_source_sm": ["parent-record"],
}
]
},
["child-record", "parent-record"],
)

assert rows == [
{
"subject_id": "child-record",
"predicate": "dct:source",
"object_id": "parent-record",
},
{
"subject_id": "parent-record",
"predicate": "dct:isSourceOf",
"object_id": "child-record",
},
]
assert "dct:sourceOf" in ALL_RELATIONSHIP_PREDICATES
4 changes: 4 additions & 0 deletions docs/backend/relationships.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ The field `dct_isPartOf_sm` is defined in the index mapping (text + keyword subf
`resource_relationships` automatically for imported records and unchanged
resources that point at them. This includes inverse replacement links such as
`dct:isReplacedBy` from a new record's `dct_replaces_sm` value.
- **Source direction**: A child stores its parent ID in `dct_source_sm`. The
relationship table exposes that as `dct:source` on the child and the inverse
`dct:isSourceOf` on the parent. Resource pages label these "Source record"
and "Derived records," respectively.
- **Make task** (from project root):
```bash
make populate-relationships
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,60 @@ describe('FullDetailsTable', () => {
expect(screen.getByText('Collection records...')).toBeInTheDocument();
});

it('labels a child dct:source relationship as its source record', () => {
const data = {
...baseData,
meta: {
ui: {
relationships: {
'dct:source': [
{
resource_id: 'parent-record',
resource_title: 'Parent record',
},
],
},
},
},
};

renderWithRouter(<FullDetailsTable data={data} />);

expect(screen.getByText('Source record...')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Parent record' })).toHaveAttribute(
'href',
'/resources/parent-record'
);
});

it('labels canonical and legacy inverse source predicates as derived records', () => {
const data = {
...baseData,
meta: {
ui: {
relationships: {
'dct:isSourceOf': [
{
resource_id: 'canonical-child',
resource_title: 'Canonical child',
},
],
'dct:sourceOf': [
{
resource_id: 'legacy-child',
resource_title: 'Legacy child',
},
],
},
},
},
};

renderWithRouter(<FullDetailsTable data={data} />);

expect(screen.getAllByText('Derived records...')).toHaveLength(2);
});

it('Browse all link for dct:hasPart uses include_filters[dct_isPartOf_sm][]', () => {
const parentId = 'eee6150b-ce2f-4837-9d17-ce72a0c1c26f';
const data = {
Expand Down Expand Up @@ -117,6 +171,37 @@ describe('FullDetailsTable', () => {
);
});

it('Browse all link for dct:isSourceOf filters children by dct_source_sm', () => {
const parentId = 'parent-record';
const data = {
...baseData,
attributes: {
...baseData.attributes,
ogm: { ...baseData.attributes.ogm, id: parentId },
},
meta: {
ui: {
relationships: {
'dct:isSourceOf': Array.from({ length: 6 }, (_, i) => ({
resource_id: `child-${i}`,
resource_title: `Child ${i}`,
})),
},
},
},
};

renderWithRouter(<FullDetailsTable data={data} />);

const browseLink = screen.getByRole('link', {
name: /Browse all 6 records/,
});
expect(browseLink).toHaveAttribute(
'href',
expect.stringContaining('include_filters[dct_source_sm]')
);
});

it('does not show Browse all link when 5 or fewer items', () => {
const data = {
...baseData,
Expand Down
14 changes: 10 additions & 4 deletions frontend/src/components/resource/FullDetailsTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,12 @@ const relationshipLabels: { [key: string]: string } = {
'dct:replaces': 'Replaces...',
isReplacedBy: 'Is replaced by...',
'dct:isReplacedBy': 'Is replaced by...',
isSourceOf: 'Source records...',
'dct:isSourceOf': 'Source records...',
source: 'Derived records...',
'dct:source': 'Derived records...',
isSourceOf: 'Derived records...',
'dct:isSourceOf': 'Derived records...',
sourceOf: 'Derived records...',
'dct:sourceOf': 'Derived records...',
source: 'Source record...',
'dct:source': 'Source record...',
isVersionOf: 'Is version of...',
'dct:isVersionOf': 'Is version of...',
hasVersion: 'Has version...',
Expand Down Expand Up @@ -468,6 +470,10 @@ export function FullDetailsTable({ data }: FullDetailsTableProps) {
hasPart: 'dct_isPartOf_sm',
'pcdm:hasMember': 'pcdm_memberOf_sm',
hasMember: 'pcdm_memberOf_sm',
'dct:isSourceOf': 'dct_source_sm',
isSourceOf: 'dct_source_sm',
'dct:sourceOf': 'dct_source_sm',
sourceOf: 'dct_source_sm',
};
const relationshipFacetField =
relationshipToFacetField[relationshipType] ??
Expand Down
Loading