"use strict";
/**
* Update package membership and links from metadata to documented data. ORE
* statements say which objects belong to the package; CiTO statements say
* which metadata object documents which data object. Callers use ResourceMap,
* which starts each graph transaction and handles rollback.
* @since 2.39.0
*/
define([
"common/ValueUtilities",
"models/resourceMap/RDFGraph",
"models/resourceMap/ResourceMapCommon",
], (ValueUtilities, RDFGraph, ResourceMapCommon) => {
const { dedupeArray, dedupeBy, requireNonEmptyString } = ValueUtilities;
const { NS, ResourceMapConflictError } = ResourceMapCommon;
const REPLACEMENT_IDENTIFIER_CONFLICT_CODE = "replacementIdentifierConflict";
/**
* Record in both directions that a metadata member documents a data member.
* @param {ResourceMap} resourceMap Resource map being updated
* @param {object} metadataNode Metadata member node
* @param {object} dataNode Documented member node
*/
function addDocumentationStatements(resourceMap, metadataNode, dataNode) {
resourceMap.graph.addStatementIfMissing({
subject: metadataNode,
predicate: NS.CITO("documents"),
object: dataNode,
});
resourceMap.graph.addStatementIfMissing({
subject: dataNode,
predicate: NS.CITO("isDocumentedBy"),
object: metadataNode,
});
}
/**
* Remove both RDF statements connecting metadata to the data it documents.
* @param {ResourceMap} resourceMap Resource map being updated
* @param {object} metadataNode Metadata member node
* @param {object} dataNode Documented member node
*/
function removeDocumentationStatements(resourceMap, metadataNode, dataNode) {
resourceMap.graph.removeStatementsMatching({
subject: metadataNode,
predicate: NS.CITO("documents"),
object: dataNode,
});
resourceMap.graph.removeStatementsMatching({
subject: dataNode,
predicate: NS.CITO("isDocumentedBy"),
object: metadataNode,
});
}
/**
* Validate member PIDs and remove duplicates.
* @param {string[]} pids Candidate member PIDs
* @returns {string[]} Normalized member PIDs
*/
function normalizeMemberPids(pids) {
return dedupeArray(
pids.map((pid) => requireNonEmptyString(pid, "PID required")),
);
}
/**
* Build a stable key from a documentation link's metadata and data PIDs.
* @param {ResMapDocLink} link Documentation link
* @returns {string} Stable link key
*/
function documentationLinkKey({ metadataPid, dataPid }) {
return RDFGraph.buildKey([metadataPid, dataPid]);
}
/**
* Validate metadata and data PIDs and remove exact duplicate links.
* @param {ResMapDocLink[]} links Candidate documentation links
* @returns {ResMapDocLink[]} Normalized documentation links
*/
function normalizeDocumentationLinks(links) {
return dedupeBy(
links.map((link) => ({
metadataPid: requireNonEmptyString(
link?.metadataPid,
"Metadata PID required",
),
dataPid: requireNonEmptyString(link?.dataPid, "Data PID required"),
})),
documentationLinkKey,
);
}
/**
* Require both objects in every documentation link to be package members.
* @param {ResMapDocLink[]} links Documentation links to inspect
* @param {Function} isAggregated Member predicate
* @throws {Error} When a link endpoint is not a package member
*/
function assertDocumentationLinksAggregated(links, isAggregated) {
links.forEach(({ metadataPid, dataPid }) => {
if (!isAggregated(metadataPid)) {
throw new Error(`Metadata "${metadataPid}" is not aggregated`);
}
if (!isAggregated(dataPid)) {
throw new Error(`Data "${dataPid}" is not aggregated`);
}
});
}
/**
* Find the exact RDF nodes for the metadata and data in one documentation
* link.
* @param {ResourceMap} resourceMap Resource map being inspected
* @param {ResMapDocLink} link Documentation link to resolve
* @returns {{metadataNode: NamedNode, dataNode: NamedNode}} Endpoint nodes
*/
function resolveDocumentationNodes(resourceMap, link) {
const metadataMember = resourceMap.resolveMemberNode(link.metadataPid);
const dataMember = resourceMap.resolveMemberNode(link.dataPid);
return {
metadataNode: RDFGraph.createNamedNode(
metadataMember?.uri || resourceMap.pidToUri(link.metadataPid),
),
dataNode: RDFGraph.createNamedNode(
dataMember?.uri || resourceMap.pidToUri(link.dataPid),
),
};
}
/**
* Find the documentation links that must be added or removed to reach the
* requested set.
* @param {ResourceMap} resourceMap Resource map being inspected
* @param {ResMapDocLink[]} currentLinks Current documentation links
* @param {ResMapDocLink[]} nextLinks Desired documentation links
* @returns {object} Added and removed endpoint node pairs
*/
function diffDocumentationLinks(resourceMap, currentLinks, nextLinks) {
const currentByKey = new Map(
currentLinks.map((link) => [documentationLinkKey(link), link]),
);
const nextByKey = new Map(
nextLinks.map((link) => [documentationLinkKey(link), link]),
);
const removed = [...currentByKey]
.filter(([key]) => !nextByKey.has(key))
.map(([, link]) => resolveDocumentationNodes(resourceMap, link));
const added = [];
nextByKey.forEach((link, key) => {
const { metadataNode, dataNode } = resolveDocumentationNodes(
resourceMap,
link,
);
const isComplete =
currentByKey.has(key) &&
resourceMap.graph.hasStatement({
subject: metadataNode,
predicate: NS.CITO("documents"),
object: dataNode,
}) &&
resourceMap.graph.hasStatement({
subject: dataNode,
predicate: NS.CITO("isDocumentedBy"),
object: metadataNode,
});
if (!isComplete) {
added.push({ metadataNode, dataNode });
}
});
return { removed, added };
}
/**
* Find the package members that must be added or removed to reach the
* requested set.
* @param {ResourceMap} resourceMap Resource map being inspected
* @param {string[]} nextPids Desired member PIDs
* @returns {object} Added and removed member descriptors
*/
function diffMemberPids(resourceMap, nextPids) {
const currentPids = resourceMap.getMemberPids();
const currentPidSet = new Set(currentPids);
const nextPidSet = new Set(nextPids);
const addedMembers = nextPids
.filter((pid) => !currentPidSet.has(pid))
// A provenance node that claims the same PID is not automatically a
// package member. New members use the configured resolver instead of
// adopting that separate node.
.map((pid) => ({ pid, uri: resourceMap.pidToUri(pid) }));
const removedMembers = currentPids
.filter((pid) => !nextPidSet.has(pid))
.map((pid) => resourceMap.resolveMemberNode(pid));
return { addedMembers, removedMembers };
}
/**
* Find provenance relationships that must be cleaned up before members are
* removed.
* @param {ResourceMap} resourceMap Resource map being inspected
* @param {object[]} removedMembers Members being removed
* @returns {object|null} Collected provenance edits
*/
function collectProvenanceRemovals(resourceMap, removedMembers) {
return removedMembers.length
? resourceMap.provenance.collectMemberReferenceRemovals(
new Set(removedMembers.map(({ pid }) => pid)),
)
: null;
}
/**
* Remove package members and the provenance relationships that refer to them.
* @param {ResourceMap} resourceMap Resource map being updated
* @param {object[]} removedMembers Members being removed
* @param {object|null} provenanceRemovals Collected provenance edits
*/
function applyMemberRemovals(
resourceMap,
removedMembers,
provenanceRemovals,
) {
if (provenanceRemovals) {
resourceMap.provenance.applyMemberReferenceRemovals(provenanceRemovals);
}
removedMembers.forEach(({ uri }) => {
// Removing a member deletes every statement that references its exact
// aggregated node. Other nodes that claim the same PID remain; only their
// managed provenance links to the removed PID are deleted.
resourceMap.graph.removeNodeReferences(RDFGraph.createNamedNode(uri));
});
}
/**
* Update package membership and links from metadata to documented data for
* one Resource Map.
* @class ResourceMapStructureMutation
* @classcategory Models/ResourceMap
* @since 2.39.0
*/
class ResourceMapStructureMutation {
/**
* Create a structure mutation helper for one ResourceMap.
* @param {object} options Mutation options
* @param {ResourceMap} options.resourceMap Resource map being updated
*/
constructor({ resourceMap } = {}) {
this.resourceMap = resourceMap;
}
/**
* Add the two RDF statements that make an object a package member:
* `package --aggregates--> member` and
* `member --isAggregatedBy--> package`.
* @param {string} pid Member PID
* @param {string} uri Member node URI
* @returns {ResourceMap} Updated resource map
*/
addAggregationTriples(pid, uri) {
const { resourceMap } = this;
const memberNode = RDFGraph.createNamedNode(uri);
const aggregationNode = RDFGraph.createNamedNode(
resourceMap.aggregationUri,
);
resourceMap.ensureIdentifierForUri(uri, pid);
resourceMap.graph.addStatementIfMissing({
subject: aggregationNode,
predicate: NS.ORE("aggregates"),
object: memberNode,
});
resourceMap.graph.addStatementIfMissing({
subject: memberNode,
predicate: NS.ORE("isAggregatedBy"),
object: aggregationNode,
});
return resourceMap;
}
/**
* Remove package members and the managed relationships attached to their
* exact RDF nodes.
* @param {string[]} pids Member PIDs to remove
* @returns {ResourceMap} Updated resource map
*/
removeMembers(pids) {
const { resourceMap } = this;
const normalizedPids = normalizeMemberPids(
Array.isArray(pids) ? pids : [pids],
);
const removedMembers = normalizedPids
.map((pid) => resourceMap.resolveMemberNode(pid))
.filter(Boolean);
if (!removedMembers.length) return resourceMap;
const provenanceRemovals = collectProvenanceRemovals(
resourceMap,
removedMembers,
);
return resourceMap.mutateGraph(() => {
applyMemberRemovals(resourceMap, removedMembers, provenanceRemovals);
});
}
/**
* Change a package member's PID wherever managed package relationships use
* it.
* @param {string} oldPid Existing member PID
* @param {string} newPid Replacement member PID
* @returns {ResourceMap} Updated resource map
* @throws {Error} When the source is missing or the replacement conflicts
*/
replaceMember(oldPid, newPid) {
const { resourceMap } = this;
const normalizedOldPid = requireNonEmptyString(oldPid, "oldPid required");
const normalizedNewPid = requireNonEmptyString(newPid, "newPid required");
if (normalizedOldPid === normalizedNewPid) return resourceMap;
const oldMember = resourceMap.resolveMemberNode(normalizedOldPid);
if (!oldMember) {
throw new Error(
`Cannot replace member: "${normalizedOldPid}" is not in this resource map`,
);
}
const oldMemberUri = oldMember.uri;
const existingNewMemberUri =
resourceMap.resolveMemberNode(normalizedNewPid)?.uri;
if (existingNewMemberUri && existingNewMemberUri !== oldMemberUri) {
throw new ResourceMapConflictError(
`Cannot replace member: "${normalizedNewPid}" is already aggregated`,
{
code: "memberAlreadyAggregated",
details: {
oldPid: normalizedOldPid,
newPid: normalizedNewPid,
},
},
);
}
const nextMemberUri = resourceMap.pidToUri(normalizedNewPid);
if (
nextMemberUri !== oldMemberUri &&
resourceMap.normalization.getEndpointPidForNormalization(
RDFGraph.createNamedNode(nextMemberUri),
) !== normalizedNewPid
) {
throw new ResourceMapConflictError(
`Cannot replace member: destination for "${normalizedNewPid}" has conflicting identity`,
{
code: REPLACEMENT_IDENTIFIER_CONFLICT_CODE,
details: {
oldPid: normalizedOldPid,
newPid: normalizedNewPid,
uri: nextMemberUri,
},
},
);
}
return resourceMap.mutateGraph(() => {
if (nextMemberUri !== oldMemberUri) {
resourceMap.graph.replaceNodeValue(oldMemberUri, nextMemberUri);
}
resourceMap.setIdentifierForUri(nextMemberUri, normalizedNewPid, {
removeValues: [normalizedOldPid],
});
});
}
/**
* Set all package members and metadata documentation links in one edit. If
* any step fails, the whole edit is rolled back.
* @param {string[]} pids Desired member PIDs
* @param {ResMapDocLink[]} links Desired documentation links
* @returns {ResourceMap} Updated resource map
* @throws {Error} When inputs are invalid or reference non-members
*/
setPackageStructure(pids, links) {
if (!Array.isArray(pids)) {
throw new Error("pids must be an array");
}
if (!Array.isArray(links)) {
throw new Error("links must be an array");
}
const { resourceMap } = this;
const normalizedPids = normalizeMemberPids(pids);
const nextPidSet = new Set(normalizedPids);
const normalizedLinks = normalizeDocumentationLinks(links);
assertDocumentationLinksAggregated(normalizedLinks, (pid) =>
nextPidSet.has(pid),
);
const { addedMembers, removedMembers } = diffMemberPids(
resourceMap,
normalizedPids,
);
const { removed: removedDocs, added: addedDocs } = diffDocumentationLinks(
resourceMap,
resourceMap.getDocumentationLinks(),
normalizedLinks,
);
if (
!removedMembers.length &&
!addedMembers.length &&
!removedDocs.length &&
!addedDocs.length
) {
return resourceMap;
}
const provenanceRemovals = collectProvenanceRemovals(
resourceMap,
removedMembers,
);
return resourceMap.mutateGraph(
() => {
removedDocs.forEach(({ metadataNode, dataNode }) => {
removeDocumentationStatements(resourceMap, metadataNode, dataNode);
});
applyMemberRemovals(resourceMap, removedMembers, provenanceRemovals);
addedMembers.forEach(({ pid, uri }) => {
this.addAggregationTriples(pid, uri);
});
addedDocs.forEach(({ metadataNode, dataNode }) => {
addDocumentationStatements(resourceMap, metadataNode, dataNode);
});
},
{ rollbackOnError: true },
);
}
/**
* Record in both directions that one metadata member documents a data
* member.
* @param {string} metadataPid Documenting metadata PID
* @param {string} dataPid Documented member PID
* @returns {ResourceMap} Updated resource map
*/
linkDocumentation(metadataPid, dataPid) {
const { resourceMap } = this;
const metadataMember = resourceMap.resolveMemberNode(metadataPid, {
required: true,
message: "Metadata PID required",
});
const dataMember = resourceMap.resolveMemberNode(dataPid, {
required: true,
message: "Data PID required",
});
return resourceMap.mutateGraph(() => {
resourceMap.ensureIdentifierForUri(
metadataMember.uri,
metadataMember.pid,
);
resourceMap.ensureIdentifierForUri(dataMember.uri, dataMember.pid);
addDocumentationStatements(
resourceMap,
metadataMember.node,
dataMember.node,
);
});
}
/**
* Remove both RDF directions of a link from metadata to documented data.
* @param {string} metadataPid Documenting metadata PID
* @param {string} dataPid Documented member PID
* @returns {ResourceMap} Updated resource map
*/
unlinkDocumentation(metadataPid, dataPid) {
const { resourceMap } = this;
const metadataMember = resourceMap.resolveMemberNode(metadataPid, {
required: true,
message: "Metadata PID required",
});
const dataMember = resourceMap.resolveMemberNode(dataPid, {
required: true,
message: "Data PID required",
});
return resourceMap.mutateGraph(() => {
removeDocumentationStatements(
resourceMap,
metadataMember.node,
dataMember.node,
);
});
}
/**
* Replace all links from metadata to documented data.
* @param {ResMapDocLink[]} links Desired documentation links
* @returns {ResourceMap} Updated resource map
* @throws {Error} When links is invalid or references non-members
*/
setDocumentationLinks(links) {
if (!Array.isArray(links)) {
throw new Error("links must be an array");
}
const { resourceMap } = this;
const normalizedLinks = normalizeDocumentationLinks(links);
assertDocumentationLinksAggregated(normalizedLinks, (pid) =>
resourceMap.graphState.hasMember(pid),
);
const { removed, added } = diffDocumentationLinks(
resourceMap,
resourceMap.getDocumentationLinks(),
normalizedLinks,
);
if (!removed.length && !added.length) return resourceMap;
return resourceMap.mutateGraph(() => {
removed.forEach(({ metadataNode, dataNode }) => {
removeDocumentationStatements(resourceMap, metadataNode, dataNode);
});
added.forEach(({ metadataNode, dataNode }) => {
addDocumentationStatements(resourceMap, metadataNode, dataNode);
});
});
}
}
return ResourceMapStructureMutation;
});