Source: src/js/models/resourceMap/ResourceMapValidation.js

"use strict";

/**
 * Check Resource Map identity, structure, documentation, dates, and provenance.
 * @module ResourceMapValidation
 * @since 2.39.0
 */

define([
  "common/DateUtilities",
  "common/ValidationUtilities",
  "common/ValueUtilities",
  "models/resourceMap/RDFGraph",
  "models/resourceMap/ResourceMapCommon",
  "models/resourceMap/ResourceMapNormalization",
], (
  DateUtilities,
  ValidationUtilities,
  ValueUtilities,
  RDFGraph,
  ResourceMapCommon,
  ResourceMapNormalization,
) => {
  const { createValidationIssue } = ValidationUtilities;
  const { isNonEmptyString } = ValueUtilities;
  const { NS, describeTerm, identifierLiteralPid, isAbsoluteNamedNode } =
    ResourceMapCommon;

  /**
   * Collect every original identifier statement and PID claim for one Resource
   * Map or package member node.
   * @param {ResourceMap} resourceMap Resource Map being inspected
   * @param {NamedNode} node Managed node
   * @returns {object} Raw identifier claims
   */
  function inspectIdentifiers(resourceMap, node) {
    const statements = resourceMap.graph.findStatements({
      subject: node,
      predicate: NS.DCTERMS("identifier"),
    });
    const literalStatements = statements.filter(({ object }) =>
      RDFGraph.isLiteral(object),
    );
    const literalValues = literalStatements.map(({ object }) =>
      RDFGraph.getLiteralValue(object),
    );
    // Read identifier text against the PID from the node URL or caller.
    // Keep an exact match when the PID itself is a full URL.
    const endpointPids = ResourceMapCommon.configuredEndpointPids(
      node.value,
      resourceMap,
    );
    let expectedPid = endpointPids.length === 1 ? endpointPids[0] : null;
    if (node.value === resourceMap.resourceMapUri) {
      expectedPid = resourceMap.resourceMapPid;
    }
    const literalClaims = literalStatements.map(({ object }) => ({
      object,
      pid: identifierLiteralPid(object, expectedPid, resourceMap),
    }));
    return {
      statements,
      literalValues,
      literalPids: Array.from(
        new Set(literalClaims.map(({ pid }) => pid).filter(isNonEmptyString)),
      ),
      invalidLiteralValues: literalClaims
        .filter(({ pid }) => !isNonEmptyString(pid))
        .map(({ object }) => describeTerm(object)),
      resourceValues: statements
        .filter(({ object }) => !RDFGraph.isLiteral(object))
        .map(({ object }) => describeTerm(object)),
      endpointPids,
    };
  }

  /**
   * Add a validation issue about an original Resource Map or member identity.
   * @param {object[]} issues Target issue list
   * @param {string} code Stable issue code
   * @param {string} message Human readable message
   * @param {object} [details] Diagnostic details
   */
  function addIdentityIssue(issues, code, message, details = {}) {
    issues.push(
      createValidationIssue({
        field: "resourceMap",
        code,
        message,
        ...details,
      }),
    );
  }

  /**
   * Collect package member RDF nodes from both directions of the ORE membership
   * relationship before building a summary keyed by PID.
   * @param {ResourceMap} resourceMap Resource Map being inspected
   * @param {object[]} issues Target issue list
   * @returns {Array<{node:NamedNode,forward:boolean,inverse:boolean}>} Package
   * member nodes and the membership directions in which each was found
   */
  function collectRawMembers(resourceMap, issues) {
    const aggregationNode = RDFGraph.createNamedNode(
      resourceMap.aggregationUri,
    );
    const byKey = new Map();
    const addMember = (node, source) => {
      if (!isAbsoluteNamedNode(node)) {
        const relative =
          RDFGraph.isNamedNode(node) &&
          !ResourceMapCommon.isAbsoluteIri(node.value);
        addIdentityIssue(
          issues,
          relative ? "relativeMember" : "invalidMemberTerm",
          relative
            ? "A package member has a relative RDF identity and cannot be edited safely."
            : "A package membership statement does not identify a URI resource.",
          {
            source,
            member: describeTerm(node),
          },
        );
        return;
      }

      const key = RDFGraph.buildTermKey(node);
      const member = byKey.get(key) || {
        node,
        forward: false,
        inverse: false,
      };
      member[source] = true;
      byKey.set(key, member);
    };

    resourceMap.graph
      .findStatements({
        subject: aggregationNode,
        predicate: NS.ORE("aggregates"),
      })
      .forEach(({ object }) => addMember(object, "forward"));

    resourceMap.graph
      .findStatements({
        predicate: NS.ORE("isAggregatedBy"),
        object: aggregationNode,
      })
      .forEach(({ subject }) => addMember(subject, "inverse"));

    return Array.from(byKey.values());
  }

  /**
   * Check the original Resource Map and member identity statements before any
   * repair or summary keyed by PID can hide contradictions.
   * @param {ResourceMap} resourceMap Resource Map being inspected
   * @returns {{issues:object[],root:object,members:object[]}} Raw identity
   */
  function inspectRawIdentity(resourceMap) {
    const issues = [];
    const rootNode = RDFGraph.createNamedNode(resourceMap.resourceMapUri);
    const rootIdentifiers = inspectIdentifiers(resourceMap, rootNode);
    const rootLiteralPid =
      rootIdentifiers.literalPids.length === 1
        ? rootIdentifiers.literalPids[0]
        : null;

    if (rootIdentifiers.resourceValues.length) {
      addIdentityIssue(
        issues,
        "resourceValuedIdentifier",
        "The Resource Map identifier must be a literal value.",
        {
          resourceUri: rootNode.value,
          identifiers: rootIdentifiers.resourceValues,
        },
      );
    }
    if (
      rootIdentifiers.literalPids.length > 1 ||
      (rootIdentifiers.invalidLiteralValues.length > 0 &&
        rootIdentifiers.literalPids.length > 0) ||
      (rootLiteralPid && rootLiteralPid !== resourceMap.resourceMapPid)
    ) {
      addIdentityIssue(
        issues,
        "resourceMapIdentifierMismatch",
        "The Resource Map contains contradictory identifier claims.",
        {
          resourceUri: rootNode.value,
          outerPid: resourceMap.resourceMapPid,
          literalValues: rootIdentifiers.literalValues,
          literalPids: rootIdentifiers.literalPids,
          invalidLiteralValues: rootIdentifiers.invalidLiteralValues,
        },
      );
    }
    if (
      rootIdentifiers.endpointPids.some(
        (pid) =>
          pid !== resourceMap.resourceMapPid ||
          (rootLiteralPid && pid !== rootLiteralPid),
      )
    ) {
      addIdentityIssue(
        issues,
        "resourceMapIdentifierMismatch",
        "The configured endpoint, embedded identifier, and Resource Map object PID do not agree.",
        {
          resourceUri: rootNode.value,
          outerPid: resourceMap.resourceMapPid,
          literalPids: rootIdentifiers.literalPids,
          endpointPids: rootIdentifiers.endpointPids,
        },
      );
    }
    if (
      rootIdentifiers.statements.length > 0 &&
      rootIdentifiers.literalPids.length === 0 &&
      rootIdentifiers.resourceValues.length === 0
    ) {
      addIdentityIssue(
        issues,
        "missingResourceMapIdentifier",
        "The Resource Map identifier literal is empty.",
        { resourceUri: rootNode.value },
      );
    }

    const members = collectRawMembers(resourceMap, issues).map((member) => {
      const identifiers = inspectIdentifiers(resourceMap, member.node);
      const literalMemberPid =
        identifiers.literalPids.length === 1
          ? identifiers.literalPids[0]
          : null;
      const endpointPid =
        identifiers.endpointPids.length === 1
          ? identifiers.endpointPids[0]
          : null;

      if (identifiers.resourceValues.length) {
        addIdentityIssue(
          issues,
          "resourceValuedIdentifier",
          "A package member identifier must be a literal value.",
          {
            memberUri: member.node.value,
            identifiers: identifiers.resourceValues,
          },
        );
      }
      if (
        identifiers.literalPids.length > 1 ||
        identifiers.endpointPids.length > 1 ||
        (identifiers.invalidLiteralValues.length > 0 &&
          identifiers.literalPids.length > 0) ||
        (literalMemberPid && endpointPid && literalMemberPid !== endpointPid)
      ) {
        addIdentityIssue(
          issues,
          "memberIdentifierMismatch",
          "A package member contains contradictory identifier claims.",
          {
            memberUri: member.node.value,
            literalValues: identifiers.literalValues,
            literalPids: identifiers.literalPids,
            invalidLiteralValues: identifiers.invalidLiteralValues,
            endpointPids: identifiers.endpointPids,
          },
        );
      }

      const mayRepairMissingIdentifier =
        identifiers.statements.length === 0 && isNonEmptyString(endpointPid);
      const pid =
        literalMemberPid || (mayRepairMissingIdentifier ? endpointPid : null);
      if (!isNonEmptyString(pid) && identifiers.resourceValues.length === 0) {
        addIdentityIssue(
          issues,
          "missingMemberIdentifier",
          "A package member does not declare a DataONE PID.",
          { memberUri: member.node.value },
        );
      }

      return {
        ...member,
        pid,
        identifiers,
      };
    });

    const membersByPid = new Map();
    members.forEach(({ node, pid }) => {
      if (!isNonEmptyString(pid)) return;
      const values = membersByPid.get(pid) || [];
      values.push(node.value);
      membersByPid.set(pid, values);
    });
    membersByPid.forEach((memberUris, pid) => {
      if (memberUris.length > 1) {
        addIdentityIssue(
          issues,
          "ambiguousMemberPid",
          "More than one exact package member claims the same PID.",
          { pid, memberUris: memberUris.sort() },
        );
      }
    });

    return {
      issues,
      root: {
        node: rootNode,
        pid: resourceMap.resourceMapPid,
        identifiers: rootIdentifiers,
      },
      members,
    };
  }

  /**
   * Require the Resource Map document node and each package member that
   * MetacatUI edits to have exactly one identifier literal equal to its PID.
   *
   * The loader is allowed to turn equivalent imported values, such as `data.1`
   * and a configured resolver URL ending in `data.1`, into one literal containing
   * `data.1`. That repair happens only after the original statements have been
   * checked for contradictions. A later custom graph edit can add the resolver
   * form again. Both literals still claim the same PID, so the contradiction
   * check alone sees one logical value and would let both be published.
   *
   * Normal validation must not repair that later edit because doing so would
   * hide the raw statements needed to diagnose it. Instead, every managed
   * Resource Map document node and member must have exactly one identifier
   * literal, and the text of that literal must exactly equal the PID.
   *
   * This validator runs only after import repair. Running it during the original
   * identity inspection would reject safe equivalent forms before the loader
   * has a chance to replace them.
   * @param {object} identity Managed Resource Map identity
   * @returns {object[]} Validation issues
   */
  function validateCanonicalIdentifiers(identity) {
    return [identity.root, ...identity.members]
      .filter(
        ({ pid, identifiers }) =>
          identifiers.literalValues.length !== 1 ||
          identifiers.literalValues[0] !== pid,
      )
      .map(({ node, pid, identifiers }) =>
        createValidationIssue({
          field: "resourceMap",
          code: "noncanonicalIdentifier",
          message:
            "A Resource Map document or package member must have exactly one identifier literal equal to its PID.",
          nodeUri: node.value,
          pid,
          literalValues: identifiers.literalValues,
        }),
      );
  }

  /**
   * Check that the selected Resource Map, aggregation, and members have every
   * required package statement in both directions after import repair.
   * @param {object} context Resource Map validation context
   * @param {object} identity Raw identity inspection
   * @returns {object[]} Validation issues
   */
  function validatePackageStructure(context, identity) {
    const { aggregationNode, ns, resourceMap, resourceMapNode } = context;
    const { graph, graphState } = resourceMap;
    const problems = [];
    const addProblem = (reason, details = {}) =>
      problems.push({ reason, ...details });

    [
      [
        "missingResourceMapType",
        resourceMapNode,
        ns.RDF("type"),
        ns.ORE("ResourceMap"),
      ],
      [
        "missingDescribes",
        resourceMapNode,
        ns.ORE("describes"),
        aggregationNode,
      ],
      [
        "missingAggregationType",
        aggregationNode,
        ns.RDF("type"),
        ns.ORE("Aggregation"),
      ],
      [
        "missingIsDescribedBy",
        aggregationNode,
        ns.ORE("isDescribedBy"),
        resourceMapNode,
      ],
    ].forEach(([reason, subject, predicate, object]) => {
      if (!graph.hasStatement({ subject, predicate, object })) {
        addProblem(reason);
      }
    });

    if (!identity.members.length) {
      // A package may omit CiTO metadata documentation links, but it must
      // contain at least one resource.
      addProblem("missingAggregatedResource");
    }

    identity.members.forEach(({ node, pid }) => {
      if (
        !graph.hasStatement({
          subject: aggregationNode,
          predicate: ns.ORE("aggregates"),
          object: node,
        })
      ) {
        addProblem("missingAggregates", { pid, memberUri: node.value });
      }
      if (
        !graph.hasStatement({
          subject: node,
          predicate: ns.ORE("isAggregatedBy"),
          object: aggregationNode,
        })
      ) {
        addProblem("missingIsAggregatedBy", {
          pid,
          memberUri: node.value,
        });
      }
      if (isNonEmptyString(pid) && !graphState.nodeHasIdentifier(node, pid)) {
        addProblem("missingMemberIdentifier", {
          pid,
          memberUri: node.value,
        });
      }
    });

    return problems.length
      ? [
          createValidationIssue({
            field: "resourceMap",
            code: "invalidPackageStructure",
            message:
              "The Resource Map is missing a required statement between its document, aggregation, or members.",
            reasons: problems.map(({ reason }) => reason),
            problems,
          }),
        ]
      : [];
  }

  /**
   * Build a validation issue for a link from metadata to documented data that
   * cannot be represented safely.
   * @param {string} reason Machine readable reason
   * @param {object} details Diagnostic details
   * @returns {object} Validation issue
   */
  function invalidDocumentationLink(reason, details) {
    return createValidationIssue({
      field: "documentationLinks",
      code: "invalidDocumentationLink",
      message:
        "A metadata documentation statement starts or ends at an RDF node that is not an exact package member.",
      reason,
      ...details,
    });
  }

  /**
   * Check that each link from metadata to documented data starts from an exact
   * package member, points to another package member, and has the matching RDF
   * statement in the opposite direction. The DataONE indexer reads only this
   * complete member to member form.
   * @param {object} context Resource Map validation context
   * @param {object} identity Raw identity inspection
   * @returns {object[]} Validation issues
   */
  function validateDocumentationLinks(context, identity) {
    const { ns, resourceMap } = context;
    const { graph } = resourceMap;
    const memberKeys = new Set(
      identity.members.map(({ node }) => RDFGraph.buildTermKey(node)),
    );
    const singletonMemberKey =
      identity.members.length === 1
        ? RDFGraph.buildTermKey(identity.members[0].node)
        : null;
    const issues = [];

    [
      [ns.CITO("documents"), ns.CITO("isDocumentedBy")],
      [ns.CITO("isDocumentedBy"), ns.CITO("documents")],
    ].forEach(([predicate, inversePredicate]) => {
      graph.findStatements({ predicate }).forEach((statement) => {
        // The current indexer scans CiTO only when the statement subject is an
        // exact package member. Preserve statements with any other subject as
        // imported RDF that MetacatUI does not manage.
        if (!memberKeys.has(RDFGraph.buildTermKey(statement.subject))) return;

        if (!memberKeys.has(RDFGraph.buildTermKey(statement.object))) {
          issues.push(
            invalidDocumentationLink("objectNotAggregated", {
              predicate: predicate.value,
              subject: describeTerm(statement.subject),
              object: describeTerm(statement.object),
            }),
          );
          return;
        }

        if (
          !graph.hasStatement({
            subject: statement.object,
            predicate: inversePredicate,
            object: statement.subject,
          })
        ) {
          const subjectKey = RDFGraph.buildTermKey(statement.subject);
          const objectKey = RDFGraph.buildTermKey(statement.object);
          // During serialization, a package with one member receives either
          // missing CiTO direction so the current DataONE indexer can read it.
          if (
            singletonMemberKey &&
            subjectKey === singletonMemberKey &&
            objectKey === singletonMemberKey
          ) {
            return;
          }
          issues.push(
            invalidDocumentationLink("missingReciprocal", {
              predicate: predicate.value,
              subject: describeTerm(statement.subject),
              object: describeTerm(statement.object),
            }),
          );
        }
      });
    });

    return issues;
  }

  /**
   * Validate that `modified`, when present, is a valid date and time.
   * @param {object} context Resource Map validation context
   * @returns {object[]} Validation issues
   */
  function validateModifiedDate(context) {
    const { ns, resourceMap, resourceMapNode } = context;
    const invalidModifiedObject = [ns.DC("modified"), ns.DCTERMS("modified")]
      .flatMap((predicate) =>
        resourceMap.graph
          .findStatements({ subject: resourceMapNode, predicate })
          .map(({ object }) => object),
      )
      .find((object) => {
        const value = RDFGraph.getLiteralValue(object);
        return !isNonEmptyString(value) || !DateUtilities.toDate(value);
      });
    const modified =
      RDFGraph.getLiteralValue(invalidModifiedObject) ||
      invalidModifiedObject?.value ||
      null;
    return invalidModifiedObject
      ? [
          createValidationIssue({
            field: "modified",
            severity: "warning",
            code: "invalidModifiedDate",
            message: "modified must be a valid date and time when present.",
            modified,
          }),
        ]
      : [];
  }

  /**
   * Check the Resource Map document and aggregation pair, member identities,
   * package structure, documentation links, dates, and provenance.
   * @param {ResourceMap} resourceMap Resource Map to validate
   * @returns {object[]} Validation issues
   * @throws {Error} When Resource Map ownership validation fails unexpectedly
   */
  function validateResourceMap(resourceMap) {
    try {
      // mutateGraph() can add another `ore:describes` pair after construction.
      ResourceMapNormalization.selectImportedRoot(
        resourceMap.graph,
        resourceMap.resourceMapPid,
        resourceMap,
      );
    } catch (error) {
      if (error.code === "ambiguousResourceMapRoot") return error.issues;
      throw error;
    }

    const identity = inspectRawIdentity(resourceMap);
    // Raw contradictions must stop validation before State projects the graph
    // by PID and could hide the exact assertions that made editing unsafe.
    if (identity.issues.length) {
      return identity.issues;
    }
    const context = resourceMap.graphState.createValidationContext();
    return [
      ...validateCanonicalIdentifiers(identity),
      ...validatePackageStructure(context, identity),
      ...validateDocumentationLinks(context, identity),
      ...validateModifiedDate(context),
      ...resourceMap.provenance.validate(),
    ];
  }

  return { inspectRawIdentity, validateResourceMap };
});